added webhooks, moved app name and verison to simple config file
This commit is contained in:
500
app/web/api/webhooks.py
Normal file
500
app/web/api/webhooks.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
Webhooks API blueprint.
|
||||
|
||||
Handles endpoints for managing webhook configurations and viewing delivery logs.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.models import Webhook, WebhookDeliveryLog, Alert
|
||||
from web.services.webhook_service import WebhookService
|
||||
|
||||
bp = Blueprint('webhooks_api', __name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_webhooks():
|
||||
"""
|
||||
List all webhooks with optional filtering.
|
||||
|
||||
Query params:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
enabled: Filter by enabled status (true/false)
|
||||
|
||||
Returns:
|
||||
JSON response with webhooks list
|
||||
"""
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items
|
||||
enabled = request.args.get('enabled')
|
||||
|
||||
# Build query
|
||||
query = current_app.db_session.query(Webhook)
|
||||
|
||||
# Apply enabled filter
|
||||
if enabled is not None:
|
||||
enabled_bool = enabled.lower() == 'true'
|
||||
query = query.filter(Webhook.enabled == enabled_bool)
|
||||
|
||||
# Order by name
|
||||
query = query.order_by(Webhook.name)
|
||||
|
||||
# Paginate
|
||||
total = query.count()
|
||||
webhooks = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# Format response
|
||||
webhooks_data = []
|
||||
|
||||
for webhook in webhooks:
|
||||
# Parse JSON fields
|
||||
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
|
||||
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
|
||||
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
|
||||
|
||||
webhooks_data.append({
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'enabled': webhook.enabled,
|
||||
'auth_type': webhook.auth_type,
|
||||
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None, # Mask sensitive data
|
||||
'custom_headers': custom_headers,
|
||||
'alert_types': alert_types,
|
||||
'severity_filter': severity_filter,
|
||||
'timeout': webhook.timeout,
|
||||
'retry_count': webhook.retry_count,
|
||||
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
|
||||
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'webhooks': webhooks_data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': (total + per_page - 1) // per_page
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_webhook(webhook_id):
|
||||
"""
|
||||
Get a specific webhook by ID.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Returns:
|
||||
JSON response with webhook details
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Webhook {webhook_id} not found'
|
||||
}), 404
|
||||
|
||||
# Parse JSON fields
|
||||
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
|
||||
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
|
||||
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
|
||||
|
||||
return jsonify({
|
||||
'webhook': {
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'enabled': webhook.enabled,
|
||||
'auth_type': webhook.auth_type,
|
||||
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None,
|
||||
'custom_headers': custom_headers,
|
||||
'alert_types': alert_types,
|
||||
'severity_filter': severity_filter,
|
||||
'timeout': webhook.timeout,
|
||||
'retry_count': webhook.retry_count,
|
||||
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
|
||||
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_webhook():
|
||||
"""
|
||||
Create a new webhook.
|
||||
|
||||
Request body:
|
||||
name: Webhook name (required)
|
||||
url: Webhook URL (required)
|
||||
enabled: Whether webhook is enabled (default: true)
|
||||
auth_type: Authentication type (none, bearer, basic, custom)
|
||||
auth_token: Authentication token (encrypted on storage)
|
||||
custom_headers: JSON object with custom headers
|
||||
alert_types: Array of alert types to filter
|
||||
severity_filter: Array of severities to filter
|
||||
timeout: Request timeout in seconds (default: 10)
|
||||
retry_count: Number of retry attempts (default: 3)
|
||||
|
||||
Returns:
|
||||
JSON response with created webhook
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate required fields
|
||||
if not data.get('name'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'name is required'
|
||||
}), 400
|
||||
|
||||
if not data.get('url'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'url is required'
|
||||
}), 400
|
||||
|
||||
# Validate auth_type
|
||||
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
|
||||
auth_type = data.get('auth_type', 'none')
|
||||
if auth_type not in valid_auth_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
webhook_service = WebhookService(current_app.db_session)
|
||||
|
||||
# Encrypt auth_token if provided
|
||||
auth_token = None
|
||||
if data.get('auth_token'):
|
||||
auth_token = webhook_service._encrypt_value(data['auth_token'])
|
||||
|
||||
# Serialize JSON fields
|
||||
alert_types = json.dumps(data['alert_types']) if data.get('alert_types') else None
|
||||
severity_filter = json.dumps(data['severity_filter']) if data.get('severity_filter') else None
|
||||
custom_headers = json.dumps(data['custom_headers']) if data.get('custom_headers') else None
|
||||
|
||||
# Create webhook
|
||||
webhook = Webhook(
|
||||
name=data['name'],
|
||||
url=data['url'],
|
||||
enabled=data.get('enabled', True),
|
||||
auth_type=auth_type,
|
||||
auth_token=auth_token,
|
||||
custom_headers=custom_headers,
|
||||
alert_types=alert_types,
|
||||
severity_filter=severity_filter,
|
||||
timeout=data.get('timeout', 10),
|
||||
retry_count=data.get('retry_count', 3),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
current_app.db_session.add(webhook)
|
||||
current_app.db_session.commit()
|
||||
|
||||
# Parse for response
|
||||
alert_types_parsed = json.loads(alert_types) if alert_types else None
|
||||
severity_filter_parsed = json.loads(severity_filter) if severity_filter else None
|
||||
custom_headers_parsed = json.loads(custom_headers) if custom_headers else None
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Webhook created successfully',
|
||||
'webhook': {
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'enabled': webhook.enabled,
|
||||
'auth_type': webhook.auth_type,
|
||||
'alert_types': alert_types_parsed,
|
||||
'severity_filter': severity_filter_parsed,
|
||||
'custom_headers': custom_headers_parsed,
|
||||
'timeout': webhook.timeout,
|
||||
'retry_count': webhook.retry_count,
|
||||
'created_at': webhook.created_at.isoformat()
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
current_app.db_session.rollback()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to create webhook: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_webhook(webhook_id):
|
||||
"""
|
||||
Update an existing webhook.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Request body (all optional):
|
||||
name: Webhook name
|
||||
url: Webhook URL
|
||||
enabled: Whether webhook is enabled
|
||||
auth_type: Authentication type
|
||||
auth_token: Authentication token
|
||||
custom_headers: JSON object with custom headers
|
||||
alert_types: Array of alert types
|
||||
severity_filter: Array of severities
|
||||
timeout: Request timeout
|
||||
retry_count: Retry attempts
|
||||
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Webhook {webhook_id} not found'
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate auth_type if provided
|
||||
if 'auth_type' in data:
|
||||
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
|
||||
if data['auth_type'] not in valid_auth_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
webhook_service = WebhookService(current_app.db_session)
|
||||
|
||||
# Update fields if provided
|
||||
if 'name' in data:
|
||||
webhook.name = data['name']
|
||||
if 'url' in data:
|
||||
webhook.url = data['url']
|
||||
if 'enabled' in data:
|
||||
webhook.enabled = data['enabled']
|
||||
if 'auth_type' in data:
|
||||
webhook.auth_type = data['auth_type']
|
||||
if 'auth_token' in data:
|
||||
# Encrypt new token
|
||||
webhook.auth_token = webhook_service._encrypt_value(data['auth_token'])
|
||||
if 'custom_headers' in data:
|
||||
webhook.custom_headers = json.dumps(data['custom_headers']) if data['custom_headers'] else None
|
||||
if 'alert_types' in data:
|
||||
webhook.alert_types = json.dumps(data['alert_types']) if data['alert_types'] else None
|
||||
if 'severity_filter' in data:
|
||||
webhook.severity_filter = json.dumps(data['severity_filter']) if data['severity_filter'] else None
|
||||
if 'timeout' in data:
|
||||
webhook.timeout = data['timeout']
|
||||
if 'retry_count' in data:
|
||||
webhook.retry_count = data['retry_count']
|
||||
|
||||
webhook.updated_at = datetime.now(timezone.utc)
|
||||
current_app.db_session.commit()
|
||||
|
||||
# Parse for response
|
||||
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
|
||||
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
|
||||
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Webhook updated successfully',
|
||||
'webhook': {
|
||||
'id': webhook.id,
|
||||
'name': webhook.name,
|
||||
'url': webhook.url,
|
||||
'enabled': webhook.enabled,
|
||||
'auth_type': webhook.auth_type,
|
||||
'alert_types': alert_types,
|
||||
'severity_filter': severity_filter,
|
||||
'custom_headers': custom_headers,
|
||||
'timeout': webhook.timeout,
|
||||
'retry_count': webhook.retry_count,
|
||||
'updated_at': webhook.updated_at.isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.db_session.rollback()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to update webhook: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_webhook(webhook_id):
|
||||
"""
|
||||
Delete a webhook.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Webhook {webhook_id} not found'
|
||||
}), 404
|
||||
|
||||
try:
|
||||
# Delete webhook (delivery logs will be cascade deleted)
|
||||
current_app.db_session.delete(webhook)
|
||||
current_app.db_session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Webhook {webhook_id} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.db_session.rollback()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to delete webhook: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>/test', methods=['POST'])
|
||||
@api_auth_required
|
||||
def test_webhook(webhook_id):
|
||||
"""
|
||||
Send a test payload to a webhook.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Returns:
|
||||
JSON response with test result
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Webhook {webhook_id} not found'
|
||||
}), 404
|
||||
|
||||
# Test webhook delivery
|
||||
webhook_service = WebhookService(current_app.db_session)
|
||||
result = webhook_service.test_webhook(webhook_id)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success' if result['success'] else 'error',
|
||||
'message': result['message'],
|
||||
'status_code': result['status_code'],
|
||||
'response_body': result.get('response_body')
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>/logs', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_webhook_logs(webhook_id):
|
||||
"""
|
||||
Get delivery logs for a specific webhook.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Query params:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
status: Filter by status (success/failed)
|
||||
|
||||
Returns:
|
||||
JSON response with delivery logs
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Webhook {webhook_id} not found'
|
||||
}), 404
|
||||
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = min(request.args.get('per_page', 20, type=int), 100)
|
||||
status_filter = request.args.get('status')
|
||||
|
||||
# Build query
|
||||
query = current_app.db_session.query(WebhookDeliveryLog).filter(
|
||||
WebhookDeliveryLog.webhook_id == webhook_id
|
||||
)
|
||||
|
||||
# Apply status filter
|
||||
if status_filter:
|
||||
query = query.filter(WebhookDeliveryLog.status == status_filter)
|
||||
|
||||
# Order by most recent first
|
||||
query = query.order_by(WebhookDeliveryLog.delivered_at.desc())
|
||||
|
||||
# Paginate
|
||||
total = query.count()
|
||||
logs = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
# Format response
|
||||
logs_data = []
|
||||
for log in logs:
|
||||
# Get alert info
|
||||
alert = current_app.db_session.query(Alert).filter(Alert.id == log.alert_id).first()
|
||||
|
||||
logs_data.append({
|
||||
'id': log.id,
|
||||
'alert_id': log.alert_id,
|
||||
'alert_type': alert.alert_type if alert else None,
|
||||
'alert_message': alert.message if alert else None,
|
||||
'status': log.status,
|
||||
'response_code': log.response_code,
|
||||
'response_body': log.response_body,
|
||||
'error_message': log.error_message,
|
||||
'attempt_number': log.attempt_number,
|
||||
'delivered_at': log.delivered_at.isoformat() if log.delivered_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'webhook_id': webhook_id,
|
||||
'webhook_name': webhook.name,
|
||||
'logs': logs_data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'pages': (total + per_page - 1) // per_page
|
||||
})
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
|
||||
Returns:
|
||||
JSON response with API health status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'api': 'webhooks',
|
||||
'version': '1.0.0-phase5'
|
||||
})
|
||||
13
app/web/config.py
Normal file
13
app/web/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Application configuration and metadata.
|
||||
|
||||
Contains version information and other application-level constants
|
||||
that are managed by developers, not stored in the database.
|
||||
"""
|
||||
|
||||
# Application metadata
|
||||
APP_NAME = 'SneakyScanner'
|
||||
APP_VERSION = '1.0.0-phase5'
|
||||
|
||||
# Repository URL
|
||||
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'
|
||||
59
app/web/jobs/webhook_job.py
Normal file
59
app/web/jobs/webhook_job.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Background webhook delivery job execution.
|
||||
|
||||
This module handles the execution of webhook deliveries in background threads,
|
||||
updating delivery logs and handling errors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from web.services.webhook_service import WebhookService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_webhook_delivery(webhook_id: int, alert_id: int, db_url: str):
|
||||
"""
|
||||
Execute a webhook delivery in the background.
|
||||
|
||||
This function is designed to run in a background thread via APScheduler.
|
||||
It creates its own database session to avoid conflicts with the main
|
||||
application thread.
|
||||
|
||||
Args:
|
||||
webhook_id: ID of the webhook to deliver
|
||||
alert_id: ID of the alert to send
|
||||
db_url: Database connection URL
|
||||
|
||||
Workflow:
|
||||
1. Create new database session for this thread
|
||||
2. Call WebhookService to deliver webhook
|
||||
3. WebhookService handles retry logic and logging
|
||||
4. Close session
|
||||
"""
|
||||
logger.info(f"Starting background webhook delivery: webhook_id={webhook_id}, alert_id={alert_id}")
|
||||
|
||||
# Create new database session for this thread
|
||||
engine = create_engine(db_url, echo=False)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Create webhook service and deliver
|
||||
webhook_service = WebhookService(session)
|
||||
success = webhook_service.deliver_webhook(webhook_id, alert_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"Webhook {webhook_id} delivered successfully for alert {alert_id}")
|
||||
else:
|
||||
logger.warning(f"Webhook {webhook_id} delivery failed for alert {alert_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during webhook delivery: {e}", exc_info=True)
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
logger.info(f"Webhook delivery job completed: webhook_id={webhook_id}, alert_id={alert_id}")
|
||||
83
app/web/routes/webhooks.py
Normal file
83
app/web/routes/webhooks.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Webhook web routes for SneakyScanner.
|
||||
|
||||
Provides UI pages for managing webhooks and viewing delivery logs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
|
||||
from web.auth.decorators import login_required
|
||||
from web.models import Webhook
|
||||
from web.services.webhook_service import WebhookService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('webhooks', __name__)
|
||||
|
||||
|
||||
@bp.route('')
|
||||
@login_required
|
||||
def list_webhooks():
|
||||
"""
|
||||
Webhooks list page - shows all configured webhooks.
|
||||
|
||||
Returns:
|
||||
Rendered webhooks list template
|
||||
"""
|
||||
return render_template('webhooks/list.html')
|
||||
|
||||
|
||||
@bp.route('/new')
|
||||
@login_required
|
||||
def new_webhook():
|
||||
"""
|
||||
New webhook form page.
|
||||
|
||||
Returns:
|
||||
Rendered webhook form template
|
||||
"""
|
||||
return render_template('webhooks/form.html', webhook=None, mode='create')
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>/edit')
|
||||
@login_required
|
||||
def edit_webhook(webhook_id):
|
||||
"""
|
||||
Edit webhook form page.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID to edit
|
||||
|
||||
Returns:
|
||||
Rendered webhook form template or 404
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
flash('Webhook not found', 'error')
|
||||
return redirect(url_for('webhooks.list_webhooks'))
|
||||
|
||||
return render_template('webhooks/form.html', webhook=webhook, mode='edit')
|
||||
|
||||
|
||||
@bp.route('/<int:webhook_id>/logs')
|
||||
@login_required
|
||||
def webhook_logs(webhook_id):
|
||||
"""
|
||||
Webhook delivery logs page.
|
||||
|
||||
Args:
|
||||
webhook_id: Webhook ID
|
||||
|
||||
Returns:
|
||||
Rendered webhook logs template or 404
|
||||
"""
|
||||
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
|
||||
if not webhook:
|
||||
flash('Webhook not found', 'error')
|
||||
return redirect(url_for('webhooks.list_webhooks'))
|
||||
|
||||
return render_template('webhooks/logs.html', webhook=webhook)
|
||||
439
app/web/services/webhook_service.py
Normal file
439
app/web/services/webhook_service.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Webhook Service Module
|
||||
|
||||
Handles webhook delivery for alert notifications with retry logic,
|
||||
authentication support, and comprehensive logging.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
|
||||
from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookService:
|
||||
"""
|
||||
Service for webhook delivery and management.
|
||||
|
||||
Handles queuing webhook deliveries, executing HTTP requests with
|
||||
authentication, retry logic, and logging delivery attempts.
|
||||
"""
|
||||
|
||||
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
|
||||
"""
|
||||
Initialize webhook service.
|
||||
|
||||
Args:
|
||||
db_session: SQLAlchemy database session
|
||||
encryption_key: Fernet encryption key for auth_token encryption
|
||||
"""
|
||||
self.db = db_session
|
||||
self._encryption_key = encryption_key or self._get_encryption_key()
|
||||
self._cipher = Fernet(self._encryption_key) if self._encryption_key else None
|
||||
|
||||
def _get_encryption_key(self) -> Optional[bytes]:
|
||||
"""
|
||||
Get encryption key from environment or database.
|
||||
|
||||
Returns:
|
||||
Fernet encryption key or None if not available
|
||||
"""
|
||||
# Try environment variable first
|
||||
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
|
||||
if key_str:
|
||||
return key_str.encode()
|
||||
|
||||
# Try to get from settings (would need to query Setting table)
|
||||
# For now, generate a temporary key if none exists
|
||||
try:
|
||||
return Fernet.generate_key()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not generate encryption key: {e}")
|
||||
return None
|
||||
|
||||
def _encrypt_value(self, value: str) -> str:
|
||||
"""Encrypt a string value."""
|
||||
if not self._cipher:
|
||||
return value # Return plain text if encryption not available
|
||||
return self._cipher.encrypt(value.encode()).decode()
|
||||
|
||||
def _decrypt_value(self, encrypted_value: str) -> str:
|
||||
"""Decrypt an encrypted string value."""
|
||||
if not self._cipher:
|
||||
return encrypted_value # Return as-is if encryption not available
|
||||
try:
|
||||
return self._cipher.decrypt(encrypted_value.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt value: {e}")
|
||||
return encrypted_value
|
||||
|
||||
def get_matching_webhooks(self, alert: Alert) -> List[Webhook]:
|
||||
"""
|
||||
Get all enabled webhooks that match an alert's type and severity.
|
||||
|
||||
Args:
|
||||
alert: Alert object to match against
|
||||
|
||||
Returns:
|
||||
List of matching Webhook objects
|
||||
"""
|
||||
# Get all enabled webhooks
|
||||
webhooks = self.db.query(Webhook).filter(Webhook.enabled == True).all()
|
||||
|
||||
matching_webhooks = []
|
||||
for webhook in webhooks:
|
||||
# Check if webhook matches alert type filter
|
||||
if webhook.alert_types:
|
||||
try:
|
||||
alert_types = json.loads(webhook.alert_types)
|
||||
if alert.alert_type not in alert_types:
|
||||
continue # Skip if alert type doesn't match
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid alert_types JSON for webhook {webhook.id}")
|
||||
continue
|
||||
|
||||
# Check if webhook matches severity filter
|
||||
if webhook.severity_filter:
|
||||
try:
|
||||
severity_filter = json.loads(webhook.severity_filter)
|
||||
if alert.severity not in severity_filter:
|
||||
continue # Skip if severity doesn't match
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid severity_filter JSON for webhook {webhook.id}")
|
||||
continue
|
||||
|
||||
matching_webhooks.append(webhook)
|
||||
|
||||
logger.info(f"Found {len(matching_webhooks)} matching webhooks for alert {alert.id}")
|
||||
return matching_webhooks
|
||||
|
||||
def queue_webhook_delivery(self, webhook_id: int, alert_id: int, scheduler_service=None) -> bool:
|
||||
"""
|
||||
Queue a webhook delivery for async execution via APScheduler.
|
||||
|
||||
Args:
|
||||
webhook_id: ID of webhook to deliver
|
||||
alert_id: ID of alert to send
|
||||
scheduler_service: SchedulerService instance (if None, deliver synchronously)
|
||||
|
||||
Returns:
|
||||
True if queued successfully, False otherwise
|
||||
"""
|
||||
if scheduler_service and scheduler_service.scheduler:
|
||||
try:
|
||||
# Import here to avoid circular dependency
|
||||
from web.jobs.webhook_job import execute_webhook_delivery
|
||||
|
||||
# Schedule immediate execution
|
||||
scheduler_service.scheduler.add_job(
|
||||
execute_webhook_delivery,
|
||||
args=[webhook_id, alert_id, scheduler_service.db_url],
|
||||
id=f"webhook_{webhook_id}_{alert_id}_{int(time.time())}",
|
||||
replace_existing=False
|
||||
)
|
||||
logger.info(f"Queued webhook {webhook_id} for alert {alert_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to queue webhook delivery: {e}")
|
||||
# Fall back to synchronous delivery
|
||||
return self.deliver_webhook(webhook_id, alert_id)
|
||||
else:
|
||||
# No scheduler available, deliver synchronously
|
||||
logger.info(f"No scheduler available, delivering webhook {webhook_id} synchronously")
|
||||
return self.deliver_webhook(webhook_id, alert_id)
|
||||
|
||||
def deliver_webhook(self, webhook_id: int, alert_id: int, attempt_number: int = 1) -> bool:
|
||||
"""
|
||||
Deliver a webhook with retry logic.
|
||||
|
||||
Args:
|
||||
webhook_id: ID of webhook to deliver
|
||||
alert_id: ID of alert to send
|
||||
attempt_number: Current attempt number (for retries)
|
||||
|
||||
Returns:
|
||||
True if delivered successfully, False otherwise
|
||||
"""
|
||||
# Get webhook and alert
|
||||
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not webhook:
|
||||
logger.error(f"Webhook {webhook_id} not found")
|
||||
return False
|
||||
|
||||
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
logger.error(f"Alert {alert_id} not found")
|
||||
return False
|
||||
|
||||
logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})")
|
||||
|
||||
# Build payload
|
||||
payload = self._build_payload(alert)
|
||||
|
||||
# Prepare headers
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
# Add custom headers if provided
|
||||
if webhook.custom_headers:
|
||||
try:
|
||||
custom_headers = json.loads(webhook.custom_headers)
|
||||
headers.update(custom_headers)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid custom_headers JSON for webhook {webhook_id}")
|
||||
|
||||
# Prepare authentication
|
||||
auth = None
|
||||
if webhook.auth_type == 'bearer' and webhook.auth_token:
|
||||
decrypted_token = self._decrypt_value(webhook.auth_token)
|
||||
headers['Authorization'] = f'Bearer {decrypted_token}'
|
||||
elif webhook.auth_type == 'basic' and webhook.auth_token:
|
||||
# Expecting format: "username:password"
|
||||
decrypted_token = self._decrypt_value(webhook.auth_token)
|
||||
if ':' in decrypted_token:
|
||||
username, password = decrypted_token.split(':', 1)
|
||||
auth = HTTPBasicAuth(username, password)
|
||||
|
||||
# Execute HTTP request
|
||||
try:
|
||||
timeout = webhook.timeout or 10
|
||||
response = requests.post(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Log delivery attempt
|
||||
log_entry = WebhookDeliveryLog(
|
||||
webhook_id=webhook_id,
|
||||
alert_id=alert_id,
|
||||
status='success' if response.status_code < 400 else 'failed',
|
||||
response_code=response.status_code,
|
||||
response_body=response.text[:1000], # Limit to 1000 chars
|
||||
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
|
||||
attempt_number=attempt_number,
|
||||
delivered_at=datetime.now(timezone.utc)
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
|
||||
# Update alert webhook status if successful
|
||||
if response.status_code < 400:
|
||||
alert.webhook_sent = True
|
||||
alert.webhook_sent_at = datetime.now(timezone.utc)
|
||||
logger.info(f"Webhook {webhook_id} delivered successfully (HTTP {response.status_code})")
|
||||
self.db.commit()
|
||||
return True
|
||||
else:
|
||||
# Failed but got a response
|
||||
logger.warning(f"Webhook {webhook_id} failed with HTTP {response.status_code}")
|
||||
self.db.commit()
|
||||
|
||||
# Retry if attempts remaining
|
||||
if attempt_number < webhook.retry_count:
|
||||
delay = self._calculate_retry_delay(attempt_number)
|
||||
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
|
||||
time.sleep(delay)
|
||||
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
|
||||
return False
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error_msg = f"Request timeout after {timeout} seconds"
|
||||
logger.error(f"Webhook {webhook_id} timeout: {error_msg}")
|
||||
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
error_msg = f"Connection error: {str(e)}"
|
||||
logger.error(f"Webhook {webhook_id} connection error: {error_msg}")
|
||||
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.error(f"Webhook {webhook_id} request error: {error_msg}")
|
||||
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
logger.error(f"Webhook {webhook_id} unexpected error: {error_msg}")
|
||||
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
|
||||
|
||||
# Retry if attempts remaining
|
||||
if attempt_number < webhook.retry_count:
|
||||
delay = self._calculate_retry_delay(attempt_number)
|
||||
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
|
||||
time.sleep(delay)
|
||||
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
|
||||
|
||||
return False
|
||||
|
||||
def _log_delivery_failure(self, webhook_id: int, alert_id: int, error_message: str, attempt_number: int):
|
||||
"""Log a failed delivery attempt."""
|
||||
log_entry = WebhookDeliveryLog(
|
||||
webhook_id=webhook_id,
|
||||
alert_id=alert_id,
|
||||
status='failed',
|
||||
response_code=None,
|
||||
response_body=None,
|
||||
error_message=error_message[:500], # Limit error message length
|
||||
attempt_number=attempt_number,
|
||||
delivered_at=datetime.now(timezone.utc)
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
self.db.commit()
|
||||
|
||||
def _calculate_retry_delay(self, attempt_number: int) -> int:
|
||||
"""
|
||||
Calculate exponential backoff delay for retries.
|
||||
|
||||
Args:
|
||||
attempt_number: Current attempt number
|
||||
|
||||
Returns:
|
||||
Delay in seconds
|
||||
"""
|
||||
# Exponential backoff: 2^attempt seconds (2, 4, 8, 16...)
|
||||
return min(2 ** attempt_number, 60) # Cap at 60 seconds
|
||||
|
||||
def _build_payload(self, alert: Alert) -> Dict[str, Any]:
|
||||
"""
|
||||
Build JSON payload for webhook delivery.
|
||||
|
||||
Args:
|
||||
alert: Alert object
|
||||
|
||||
Returns:
|
||||
Dict containing alert details in generic JSON format
|
||||
"""
|
||||
# Get related scan
|
||||
scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first()
|
||||
|
||||
# Get related rule
|
||||
rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
|
||||
|
||||
payload = {
|
||||
"event": "alert.created",
|
||||
"alert": {
|
||||
"id": alert.id,
|
||||
"type": alert.alert_type,
|
||||
"severity": alert.severity,
|
||||
"message": alert.message,
|
||||
"ip_address": alert.ip_address,
|
||||
"port": alert.port,
|
||||
"acknowledged": alert.acknowledged,
|
||||
"created_at": alert.created_at.isoformat() if alert.created_at else None
|
||||
},
|
||||
"scan": {
|
||||
"id": scan.id if scan else None,
|
||||
"title": scan.title if scan else None,
|
||||
"timestamp": scan.timestamp.isoformat() if scan and scan.timestamp else None,
|
||||
"status": scan.status if scan else None
|
||||
},
|
||||
"rule": {
|
||||
"id": rule.id if rule else None,
|
||||
"name": rule.name if rule else None,
|
||||
"type": rule.rule_type if rule else None,
|
||||
"threshold": rule.threshold if rule else None
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
def test_webhook(self, webhook_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Send a test payload to a webhook.
|
||||
|
||||
Args:
|
||||
webhook_id: ID of webhook to test
|
||||
|
||||
Returns:
|
||||
Dict with test result details
|
||||
"""
|
||||
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not webhook:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Webhook not found',
|
||||
'status_code': None
|
||||
}
|
||||
|
||||
# Build test payload
|
||||
payload = {
|
||||
"event": "webhook.test",
|
||||
"message": "This is a test webhook from SneakyScanner",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"webhook": {
|
||||
"id": webhook.id,
|
||||
"name": webhook.name
|
||||
}
|
||||
}
|
||||
|
||||
# Prepare headers
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
if webhook.custom_headers:
|
||||
try:
|
||||
custom_headers = json.loads(webhook.custom_headers)
|
||||
headers.update(custom_headers)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Prepare authentication
|
||||
auth = None
|
||||
if webhook.auth_type == 'bearer' and webhook.auth_token:
|
||||
decrypted_token = self._decrypt_value(webhook.auth_token)
|
||||
headers['Authorization'] = f'Bearer {decrypted_token}'
|
||||
elif webhook.auth_type == 'basic' and webhook.auth_token:
|
||||
decrypted_token = self._decrypt_value(webhook.auth_token)
|
||||
if ':' in decrypted_token:
|
||||
username, password = decrypted_token.split(':', 1)
|
||||
auth = HTTPBasicAuth(username, password)
|
||||
|
||||
# Execute test request
|
||||
try:
|
||||
timeout = webhook.timeout or 10
|
||||
response = requests.post(
|
||||
webhook.url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
return {
|
||||
'success': response.status_code < 400,
|
||||
'message': f'HTTP {response.status_code}',
|
||||
'status_code': response.status_code,
|
||||
'response_body': response.text[:500]
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Request timeout after {timeout} seconds',
|
||||
'status_code': None
|
||||
}
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Connection error: {str(e)}',
|
||||
'status_code': None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'Error: {str(e)}',
|
||||
'status_code': None
|
||||
}
|
||||
369
app/web/templates/webhooks/form.html
Normal file
369
app/web/templates/webhooks/form.html
Normal file
@@ -0,0 +1,369 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Webhook - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 mb-4">
|
||||
<h1 style="color: #60a5fa;">{{ 'Edit' if mode == 'edit' else 'Create' }} Webhook</h1>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
|
||||
<li class="breadcrumb-item active">{{ 'Edit' if mode == 'edit' else 'New' }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="webhook-form">
|
||||
<!-- Basic Information -->
|
||||
<h5 class="card-title mb-3">Basic Information</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Webhook Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
placeholder="e.g., Slack Notifications">
|
||||
<div class="form-text">A descriptive name for this webhook</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="url" class="form-label">Webhook URL <span class="text-danger">*</span></label>
|
||||
<input type="url" class="form-control" id="url" name="url" required
|
||||
placeholder="https://hooks.example.com/webhook">
|
||||
<div class="form-text">The endpoint where alerts will be sent</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="enabled" name="enabled" checked>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Disabled webhooks will not receive notifications</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Authentication -->
|
||||
<h5 class="card-title mb-3">Authentication</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="auth_type" class="form-label">Authentication Type</label>
|
||||
<select class="form-select" id="auth_type" name="auth_type">
|
||||
<option value="none">None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Auth (username:password)</option>
|
||||
<option value="custom">Custom Headers</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="auth_token_field" style="display: none;">
|
||||
<label for="auth_token" class="form-label">Authentication Token</label>
|
||||
<input type="password" class="form-control" id="auth_token" name="auth_token"
|
||||
placeholder="Enter token or username:password">
|
||||
<div class="form-text" id="auth_token_help">Will be encrypted when stored</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="custom_headers_field" style="display: none;">
|
||||
<label for="custom_headers" class="form-label">Custom Headers (JSON)</label>
|
||||
<textarea class="form-control font-monospace" id="custom_headers" name="custom_headers" rows="4"
|
||||
placeholder='{"X-API-Key": "your-key", "X-Custom-Header": "value"}'></textarea>
|
||||
<div class="form-text">JSON object with custom HTTP headers</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Filters -->
|
||||
<h5 class="card-title mb-3">Alert Filters</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Alert Types</label>
|
||||
<div class="form-text mb-2">Select which alert types trigger this webhook (leave all unchecked for all types)</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input alert-type-check" type="checkbox" value="unexpected_port" id="type_unexpected">
|
||||
<label class="form-check-label" for="type_unexpected">Unexpected Port</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input alert-type-check" type="checkbox" value="drift_detection" id="type_drift">
|
||||
<label class="form-check-label" for="type_drift">Drift Detection</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input alert-type-check" type="checkbox" value="cert_expiry" id="type_cert">
|
||||
<label class="form-check-label" for="type_cert">Certificate Expiry</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input alert-type-check" type="checkbox" value="weak_tls" id="type_tls">
|
||||
<label class="form-check-label" for="type_tls">Weak TLS</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input alert-type-check" type="checkbox" value="ping_failed" id="type_ping">
|
||||
<label class="form-check-label" for="type_ping">Ping Failed</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Severity Filter</label>
|
||||
<div class="form-text mb-2">Select which severities trigger this webhook (leave all unchecked for all severities)</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input severity-check" type="checkbox" value="critical" id="severity_critical">
|
||||
<label class="form-check-label" for="severity_critical">Critical</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input severity-check" type="checkbox" value="warning" id="severity_warning">
|
||||
<label class="form-check-label" for="severity_warning">Warning</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input severity-check" type="checkbox" value="info" id="severity_info">
|
||||
<label class="form-check-label" for="severity_info">Info</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<h5 class="card-title mb-3">Advanced Settings</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="timeout" class="form-label">Timeout (seconds)</label>
|
||||
<input type="number" class="form-control" id="timeout" name="timeout" min="1" max="60" value="10">
|
||||
<div class="form-text">Maximum time to wait for response</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="retry_count" class="form-label">Retry Count</label>
|
||||
<input type="number" class="form-control" id="retry_count" name="retry_count" min="0" max="5" value="3">
|
||||
<div class="form-text">Number of retry attempts on failure</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('webhooks.list_webhooks') }}" class="btn btn-secondary">Cancel</a>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-primary me-2" id="test-btn">
|
||||
<i class="bi bi-send"></i> Test Webhook
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Save Webhook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-info-circle"></i> Help</h5>
|
||||
|
||||
<h6 class="mt-3">Payload Format</h6>
|
||||
<p class="small text-muted">Webhooks receive JSON payloads with alert details:</p>
|
||||
<pre class="small bg-dark text-light p-2 rounded"><code>{
|
||||
"event": "alert.created",
|
||||
"alert": {
|
||||
"id": 123,
|
||||
"type": "cert_expiry",
|
||||
"severity": "warning",
|
||||
"message": "...",
|
||||
"ip_address": "192.168.1.10",
|
||||
"port": 443
|
||||
},
|
||||
"scan": {...},
|
||||
"rule": {...}
|
||||
}</code></pre>
|
||||
|
||||
<h6 class="mt-3">Authentication Types</h6>
|
||||
<ul class="small">
|
||||
<li><strong>None:</strong> No authentication</li>
|
||||
<li><strong>Bearer:</strong> Add Authorization header with token</li>
|
||||
<li><strong>Basic:</strong> Use username:password format</li>
|
||||
<li><strong>Custom:</strong> Define custom HTTP headers</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">Retry Logic</h6>
|
||||
<p class="small text-muted">Failed webhooks are retried with exponential backoff (2^attempt seconds, max 60s).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const webhookId = {{ webhook.id if webhook else 'null' }};
|
||||
const mode = '{{ mode }}';
|
||||
|
||||
// Show/hide auth fields based on type
|
||||
document.getElementById('auth_type').addEventListener('change', function() {
|
||||
const authType = this.value;
|
||||
const tokenField = document.getElementById('auth_token_field');
|
||||
const headersField = document.getElementById('custom_headers_field');
|
||||
const tokenHelp = document.getElementById('auth_token_help');
|
||||
|
||||
tokenField.style.display = 'none';
|
||||
headersField.style.display = 'none';
|
||||
|
||||
if (authType === 'bearer') {
|
||||
tokenField.style.display = 'block';
|
||||
document.getElementById('auth_token').placeholder = 'Enter bearer token';
|
||||
tokenHelp.textContent = 'Bearer token for Authorization header (encrypted when stored)';
|
||||
} else if (authType === 'basic') {
|
||||
tokenField.style.display = 'block';
|
||||
document.getElementById('auth_token').placeholder = 'username:password';
|
||||
tokenHelp.textContent = 'Format: username:password (encrypted when stored)';
|
||||
} else if (authType === 'custom') {
|
||||
headersField.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Load existing webhook data if editing
|
||||
if (mode === 'edit' && webhookId) {
|
||||
loadWebhookData(webhookId);
|
||||
}
|
||||
|
||||
async function loadWebhookData(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${id}`);
|
||||
const data = await response.json();
|
||||
const webhook = data.webhook;
|
||||
|
||||
// Populate form fields
|
||||
document.getElementById('name').value = webhook.name || '';
|
||||
document.getElementById('url').value = webhook.url || '';
|
||||
document.getElementById('enabled').checked = webhook.enabled;
|
||||
document.getElementById('auth_type').value = webhook.auth_type || 'none';
|
||||
document.getElementById('timeout').value = webhook.timeout || 10;
|
||||
document.getElementById('retry_count').value = webhook.retry_count || 3;
|
||||
|
||||
// Trigger auth type change to show relevant fields
|
||||
document.getElementById('auth_type').dispatchEvent(new Event('change'));
|
||||
|
||||
// Don't populate auth_token (it's encrypted)
|
||||
if (webhook.custom_headers) {
|
||||
document.getElementById('custom_headers').value = JSON.stringify(webhook.custom_headers, null, 2);
|
||||
}
|
||||
|
||||
// Check alert types
|
||||
if (webhook.alert_types && webhook.alert_types.length > 0) {
|
||||
webhook.alert_types.forEach(type => {
|
||||
const checkbox = document.querySelector(`.alert-type-check[value="${type}"]`);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Check severities
|
||||
if (webhook.severity_filter && webhook.severity_filter.length > 0) {
|
||||
webhook.severity_filter.forEach(sev => {
|
||||
const checkbox = document.querySelector(`.severity-check[value="${sev}"]`);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading webhook:', error);
|
||||
alert('Failed to load webhook data');
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission
|
||||
document.getElementById('webhook-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
name: document.getElementById('name').value,
|
||||
url: document.getElementById('url').value,
|
||||
enabled: document.getElementById('enabled').checked,
|
||||
auth_type: document.getElementById('auth_type').value,
|
||||
timeout: parseInt(document.getElementById('timeout').value),
|
||||
retry_count: parseInt(document.getElementById('retry_count').value)
|
||||
};
|
||||
|
||||
// Add auth token if provided
|
||||
const authToken = document.getElementById('auth_token').value;
|
||||
if (authToken) {
|
||||
formData.auth_token = authToken;
|
||||
}
|
||||
|
||||
// Add custom headers if provided
|
||||
const customHeaders = document.getElementById('custom_headers').value;
|
||||
if (customHeaders.trim()) {
|
||||
try {
|
||||
formData.custom_headers = JSON.parse(customHeaders);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON in custom headers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect selected alert types
|
||||
const alertTypes = Array.from(document.querySelectorAll('.alert-type-check:checked'))
|
||||
.map(cb => cb.value);
|
||||
if (alertTypes.length > 0) {
|
||||
formData.alert_types = alertTypes;
|
||||
}
|
||||
|
||||
// Collect selected severities
|
||||
const severities = Array.from(document.querySelectorAll('.severity-check:checked'))
|
||||
.map(cb => cb.value);
|
||||
if (severities.length > 0) {
|
||||
formData.severity_filter = severities;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = mode === 'edit' ? `/api/webhooks/${webhookId}` : '/api/webhooks';
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert('Webhook saved successfully!');
|
||||
window.location.href = '{{ url_for("webhooks.list_webhooks") }}';
|
||||
} else {
|
||||
alert(`Failed to save webhook: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving webhook:', error);
|
||||
alert('Failed to save webhook');
|
||||
}
|
||||
});
|
||||
|
||||
// Test webhook button
|
||||
document.getElementById('test-btn').addEventListener('click', async function() {
|
||||
if (mode !== 'edit' || !webhookId) {
|
||||
alert('Please save the webhook first before testing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Send a test payload to this webhook?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${webhookId}/test`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
|
||||
} else {
|
||||
alert(`Test failed:\n${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing webhook:', error);
|
||||
alert('Failed to test webhook');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
250
app/web/templates/webhooks/list.html
Normal file
250
app/web/templates/webhooks/list.html
Normal file
@@ -0,0 +1,250 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Webhooks - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: #60a5fa;">Webhook Management</h1>
|
||||
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Webhook
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center my-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhooks table -->
|
||||
<div id="webhooks-container" style="display: none;">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Alert Types</th>
|
||||
<th>Severity Filter</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="webhooks-tbody">
|
||||
<!-- Populated via JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Webhooks pagination" id="pagination-container">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- Populated via JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="text-center my-5" style="display: none;">
|
||||
<i class="bi bi-webhook" style="font-size: 4rem; color: #94a3b8;"></i>
|
||||
<p class="text-muted mt-3">No webhooks configured yet.</p>
|
||||
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create Your First Webhook
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
const perPage = 20;
|
||||
|
||||
async function loadWebhooks(page = 1) {
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks?page=${page}&per_page=${perPage}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.webhooks && data.webhooks.length > 0) {
|
||||
renderWebhooks(data.webhooks);
|
||||
renderPagination(data.page, data.pages, data.total);
|
||||
document.getElementById('webhooks-container').style.display = 'block';
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('webhooks-container').style.display = 'none';
|
||||
document.getElementById('empty-state').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading webhooks:', error);
|
||||
alert('Failed to load webhooks');
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderWebhooks(webhooks) {
|
||||
const tbody = document.getElementById('webhooks-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
webhooks.forEach(webhook => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Truncate URL for display
|
||||
const truncatedUrl = webhook.url.length > 50 ?
|
||||
webhook.url.substring(0, 47) + '...' : webhook.url;
|
||||
|
||||
// Format alert types
|
||||
const alertTypes = webhook.alert_types && webhook.alert_types.length > 0 ?
|
||||
webhook.alert_types.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('') :
|
||||
'<span class="text-muted">All</span>';
|
||||
|
||||
// Format severity filter
|
||||
const severityFilter = webhook.severity_filter && webhook.severity_filter.length > 0 ?
|
||||
webhook.severity_filter.map(s => `<span class="badge bg-${getSeverityColor(s)} me-1">${s}</span>`).join('') :
|
||||
'<span class="text-muted">All</span>';
|
||||
|
||||
// Status badge
|
||||
const statusBadge = webhook.enabled ?
|
||||
'<span class="badge bg-success">Enabled</span>' :
|
||||
'<span class="badge bg-secondary">Disabled</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><strong>${escapeHtml(webhook.name)}</strong></td>
|
||||
<td><code class="small">${escapeHtml(truncatedUrl)}</code></td>
|
||||
<td>${alertTypes}</td>
|
||||
<td>${severityFilter}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary" onclick="testWebhook(${webhook.id})" title="Test">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
<a href="/webhooks/${webhook.id}/edit" class="btn btn-outline-primary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/webhooks/${webhook.id}/logs" class="btn btn-outline-info" title="Logs">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-danger" onclick="deleteWebhook(${webhook.id}, '${escapeHtml(webhook.name)}')" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(currentPage, totalPages, totalItems) {
|
||||
const pagination = document.getElementById('pagination');
|
||||
pagination.innerHTML = '';
|
||||
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('pagination-container').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('pagination-container').style.display = 'block';
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
|
||||
pagination.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
||||
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
|
||||
pagination.appendChild(li);
|
||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'page-item disabled';
|
||||
li.innerHTML = '<a class="page-link" href="#">...</a>';
|
||||
pagination.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
|
||||
pagination.appendChild(nextLi);
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadWebhooks(page);
|
||||
}
|
||||
|
||||
async function testWebhook(id) {
|
||||
if (!confirm('Send a test payload to this webhook?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
|
||||
} else {
|
||||
alert(`Test failed:\n${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing webhook:', error);
|
||||
alert('Failed to test webhook');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWebhook(id, name) {
|
||||
if (!confirm(`Are you sure you want to delete webhook "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert('Webhook deleted successfully');
|
||||
loadWebhooks(currentPage);
|
||||
} else {
|
||||
alert(`Failed to delete webhook: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting webhook:', error);
|
||||
alert('Failed to delete webhook');
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverityColor(severity) {
|
||||
const colors = {
|
||||
'critical': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return colors[severity] || 'secondary';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load webhooks on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadWebhooks(1);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
328
app/web/templates/webhooks/logs.html
Normal file
328
app/web/templates/webhooks/logs.html
Normal file
@@ -0,0 +1,328 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Webhook Logs - {{ webhook.name }} - SneakyScanner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 mb-4">
|
||||
<h1 style="color: #60a5fa;">Webhook Delivery Logs</h1>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
|
||||
<li class="breadcrumb-item active">{{ webhook.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5 class="card-title">{{ webhook.name }}</h5>
|
||||
<p class="text-muted mb-1"><strong>URL:</strong> <code>{{ webhook.url }}</code></p>
|
||||
<p class="text-muted mb-0">
|
||||
<strong>Status:</strong>
|
||||
{% if webhook.enabled %}
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<a href="{{ url_for('webhooks.edit_webhook', webhook_id=webhook.id) }}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil"></i> Edit Webhook
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="status-filter" class="form-label">Status</label>
|
||||
<select class="form-select" id="status-filter">
|
||||
<option value="">All</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-primary w-100" onclick="applyFilter()">
|
||||
<i class="bi bi-funnel"></i> Apply Filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-outline-secondary w-100" onclick="refreshLogs()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading" class="text-center my-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs table -->
|
||||
<div id="logs-container" style="display: none;">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Alert</th>
|
||||
<th>Status</th>
|
||||
<th>HTTP Code</th>
|
||||
<th>Attempt</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logs-tbody">
|
||||
<!-- Populated via JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav aria-label="Logs pagination" id="pagination-container">
|
||||
<ul class="pagination justify-content-center" id="pagination">
|
||||
<!-- Populated via JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="empty-state" class="text-center my-5" style="display: none;">
|
||||
<i class="bi bi-list-ul" style="font-size: 4rem; color: #94a3b8;"></i>
|
||||
<p class="text-muted mt-3">No delivery logs yet.</p>
|
||||
<p class="small text-muted">Logs will appear here after alerts trigger this webhook.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal for log details -->
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delivery Log Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modal-content">
|
||||
<!-- Populated via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const webhookId = {{ webhook.id }};
|
||||
let currentPage = 1;
|
||||
let currentStatus = '';
|
||||
const perPage = 20;
|
||||
|
||||
async function loadLogs(page = 1, status = '') {
|
||||
try {
|
||||
let url = `/api/webhooks/${webhookId}/logs?page=${page}&per_page=${perPage}`;
|
||||
if (status) {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
renderLogs(data.logs);
|
||||
renderPagination(data.page, data.pages, data.total);
|
||||
document.getElementById('logs-container').style.display = 'block';
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('logs-container').style.display = 'none';
|
||||
document.getElementById('empty-state').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
alert('Failed to load delivery logs');
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logs-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
logs.forEach(log => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Format timestamp
|
||||
const timestamp = new Date(log.delivered_at).toLocaleString();
|
||||
|
||||
// Status badge
|
||||
const statusBadge = log.status === 'success' ?
|
||||
'<span class="badge bg-success">Success</span>' :
|
||||
'<span class="badge bg-danger">Failed</span>';
|
||||
|
||||
// HTTP code badge
|
||||
const httpBadge = log.response_code ?
|
||||
`<span class="badge ${log.response_code < 400 ? 'bg-success' : 'bg-danger'}">${log.response_code}</span>` :
|
||||
'<span class="text-muted">N/A</span>';
|
||||
|
||||
// Alert info
|
||||
const alertInfo = log.alert_type ?
|
||||
`<span class="badge bg-secondary">${log.alert_type}</span><br><small class="text-muted">${escapeHtml(log.alert_message || '')}</small>` :
|
||||
`<small class="text-muted">Alert #${log.alert_id}</small>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<td><small>${timestamp}</small></td>
|
||||
<td>${alertInfo}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${httpBadge}</td>
|
||||
<td>${log.attempt_number || 1}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showLogDetails(${JSON.stringify(log).replace(/"/g, '"')})">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(currentPage, totalPages, totalItems) {
|
||||
const pagination = document.getElementById('pagination');
|
||||
pagination.innerHTML = '';
|
||||
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('pagination-container').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('pagination-container').style.display = 'block';
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
|
||||
pagination.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
||||
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
|
||||
pagination.appendChild(li);
|
||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'page-item disabled';
|
||||
li.innerHTML = '<a class="page-link" href="#">...</a>';
|
||||
pagination.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
|
||||
pagination.appendChild(nextLi);
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadLogs(page, currentStatus);
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
currentStatus = document.getElementById('status-filter').value;
|
||||
currentPage = 1;
|
||||
loadLogs(1, currentStatus);
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
loadLogs(currentPage, currentStatus);
|
||||
}
|
||||
|
||||
function showLogDetails(log) {
|
||||
const modalContent = document.getElementById('modal-content');
|
||||
|
||||
let detailsHTML = `
|
||||
<div class="mb-3">
|
||||
<strong>Log ID:</strong> ${log.id}<br>
|
||||
<strong>Alert ID:</strong> ${log.alert_id}<br>
|
||||
<strong>Status:</strong> <span class="badge ${log.status === 'success' ? 'bg-success' : 'bg-danger'}">${log.status}</span><br>
|
||||
<strong>HTTP Code:</strong> ${log.response_code || 'N/A'}<br>
|
||||
<strong>Attempt:</strong> ${log.attempt_number || 1}<br>
|
||||
<strong>Delivered At:</strong> ${new Date(log.delivered_at).toLocaleString()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (log.response_body) {
|
||||
detailsHTML += `
|
||||
<div class="mb-3">
|
||||
<strong>Response Body:</strong>
|
||||
<pre class="bg-dark text-light p-2 rounded mt-2"><code>${escapeHtml(log.response_body)}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (log.error_message) {
|
||||
detailsHTML += `
|
||||
<div class="mb-3">
|
||||
<strong>Error Message:</strong>
|
||||
<div class="alert alert-danger mt-2">${escapeHtml(log.error_message)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modalContent.innerHTML = detailsHTML;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load logs on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadLogs(1);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
BIN
docs/alerts.png
Normal file
BIN
docs/alerts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/config_editor.png
Normal file
BIN
docs/config_editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/configs.png
Normal file
BIN
docs/configs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/scans.png
Normal file
BIN
docs/scans.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Reference in New Issue
Block a user