update webapp
This commit is contained in:
@@ -55,10 +55,29 @@ CREATE TABLE IF NOT EXISTS client_logos (
|
|||||||
INDEX idx_short_name (short_name)
|
INDEX idx_short_name (short_name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Webhook queue table for debounced notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_queue (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
webhook_type VARCHAR(50) NOT NULL DEFAULT 'geofeed_update',
|
||||||
|
trigger_reason VARCHAR(255) DEFAULT NULL,
|
||||||
|
entries_affected INT DEFAULT 0,
|
||||||
|
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
scheduled_for TIMESTAMP NULL,
|
||||||
|
processed_at TIMESTAMP NULL,
|
||||||
|
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||||
|
response_code INT DEFAULT NULL,
|
||||||
|
response_body TEXT DEFAULT NULL,
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_scheduled (scheduled_for)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Insert default settings
|
-- Insert default settings
|
||||||
INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
|
INSERT INTO geofeed_settings (setting_key, setting_value) VALUES
|
||||||
('bunny_cdn_storage_zone', ''),
|
('bunny_cdn_storage_zone', ''),
|
||||||
('bunny_cdn_api_key', ''),
|
('bunny_cdn_api_key', ''),
|
||||||
('bunny_cdn_file_path', '/geofeed.csv'),
|
('bunny_cdn_file_path', '/geofeed.csv'),
|
||||||
('last_export_at', NULL)
|
('last_export_at', NULL),
|
||||||
|
('n8n_webhook_url', ''),
|
||||||
|
('n8n_webhook_enabled', '0'),
|
||||||
|
('n8n_webhook_delay_minutes', '3')
|
||||||
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||||
|
|||||||
@@ -1,34 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "Geofeed Export to BunnyCDN",
|
"name": "Geofeed Export to BunnyCDN",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "geofeed-update",
|
||||||
|
"responseMode": "onReceived",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "webhook-trigger",
|
||||||
|
"name": "Webhook Trigger",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [240, 200],
|
||||||
|
"webhookId": "geofeed-update"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"rule": {
|
"rule": {
|
||||||
"interval": [
|
"interval": [
|
||||||
{
|
{
|
||||||
"field": "hours",
|
"field": "hours",
|
||||||
"hoursInterval": 1
|
"hoursInterval": 24
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"id": "schedule-trigger",
|
"id": "schedule-trigger",
|
||||||
"name": "Hourly Trigger",
|
"name": "Daily Backup Trigger",
|
||||||
"type": "n8n-nodes-base.scheduleTrigger",
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
"typeVersion": 1.1,
|
"typeVersion": 1.1,
|
||||||
"position": [240, 300]
|
"position": [240, 400]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Log the trigger source for debugging\nconst webhookData = $('Webhook Trigger').item?.json || null;\nconst scheduleData = $('Daily Backup Trigger').item?.json || null;\n\nlet triggerSource = 'unknown';\nlet triggerReason = '';\nlet entriesAffected = 0;\nlet isImmediate = false;\n\nif (webhookData) {\n triggerSource = 'webhook';\n triggerReason = webhookData.trigger_reason || 'webhook_trigger';\n entriesAffected = webhookData.entries_affected || 0;\n isImmediate = webhookData.immediate || false;\n} else if (scheduleData) {\n triggerSource = 'schedule';\n triggerReason = 'daily_backup';\n}\n\nreturn [{\n json: {\n triggerSource,\n triggerReason,\n entriesAffected,\n isImmediate,\n timestamp: new Date().toISOString()\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "code-merge-triggers",
|
||||||
|
"name": "Process Trigger",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [460, 300]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"operation": "executeQuery",
|
"operation": "executeQuery",
|
||||||
"query": "SELECT ip_prefix, IFNULL(country_code, '') as country_code, IFNULL(region_code, '') as region_code, IFNULL(city, '') as city, IFNULL(postal_code, '') as postal_code FROM geofeed_entries ORDER BY ip_prefix",
|
"query": "SELECT ip_prefix, IFNULL(country_code, '') as country_code, IFNULL(region_code, '') as region_code, IFNULL(city, '') as city, IFNULL(postal_code, '') as postal_code FROM geofeed_entries ORDER BY CASE WHEN ip_prefix LIKE '%:%' THEN 1 ELSE 0 END, INET_ATON(SUBSTRING_INDEX(ip_prefix, '/', 1)), ip_prefix",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "mysql-query",
|
"id": "mysql-query",
|
||||||
"name": "Query Geofeed Entries",
|
"name": "Query Geofeed Entries",
|
||||||
"type": "n8n-nodes-base.mySql",
|
"type": "n8n-nodes-base.mySql",
|
||||||
"typeVersion": 2.3,
|
"typeVersion": 2.3,
|
||||||
"position": [460, 300],
|
"position": [680, 300],
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"mySql": {
|
"mySql": {
|
||||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||||
@@ -44,7 +68,7 @@
|
|||||||
"name": "Build CSV Content",
|
"name": "Build CSV Content",
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [680, 300]
|
"position": [900, 300]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -79,7 +103,7 @@
|
|||||||
"name": "Upload to BunnyCDN",
|
"name": "Upload to BunnyCDN",
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.2,
|
"typeVersion": 4.2,
|
||||||
"position": [900, 300]
|
"position": [1120, 300]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -108,19 +132,19 @@
|
|||||||
"name": "Check Upload Success",
|
"name": "Check Upload Success",
|
||||||
"type": "n8n-nodes-base.if",
|
"type": "n8n-nodes-base.if",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [1120, 300]
|
"position": [1340, 300]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"operation": "executeQuery",
|
"operation": "executeQuery",
|
||||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'success', 'timestamp', NOW()), 'n8n_workflow')",
|
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'success', 'trigger', '{{ $('Process Trigger').item.json.triggerSource }}', 'reason', '{{ $('Process Trigger').item.json.triggerReason }}', 'entries', {{ $('Build CSV Content').item.json.entryCount }}, 'timestamp', NOW()), 'n8n_workflow')",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "mysql-log-success",
|
"id": "mysql-log-success",
|
||||||
"name": "Log Export Success",
|
"name": "Log Export Success",
|
||||||
"type": "n8n-nodes-base.mySql",
|
"type": "n8n-nodes-base.mySql",
|
||||||
"typeVersion": 2.3,
|
"typeVersion": 2.3,
|
||||||
"position": [1340, 200],
|
"position": [1560, 200],
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"mySql": {
|
"mySql": {
|
||||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||||
@@ -138,7 +162,7 @@
|
|||||||
"name": "Update Last Export Time",
|
"name": "Update Last Export Time",
|
||||||
"type": "n8n-nodes-base.mySql",
|
"type": "n8n-nodes-base.mySql",
|
||||||
"typeVersion": 2.3,
|
"typeVersion": 2.3,
|
||||||
"position": [1560, 200],
|
"position": [1780, 200],
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"mySql": {
|
"mySql": {
|
||||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||||
@@ -149,14 +173,14 @@
|
|||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"operation": "executeQuery",
|
"operation": "executeQuery",
|
||||||
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'failed', 'timestamp', NOW()), 'n8n_workflow')",
|
"query": "INSERT INTO geofeed_audit_log (entry_id, action, new_values, changed_by) VALUES (NULL, 'INSERT', JSON_OBJECT('type', 'csv_export', 'status', 'failed', 'trigger', '{{ $('Process Trigger').item.json.triggerSource }}', 'timestamp', NOW()), 'n8n_workflow')",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "mysql-log-failure",
|
"id": "mysql-log-failure",
|
||||||
"name": "Log Export Failure",
|
"name": "Log Export Failure",
|
||||||
"type": "n8n-nodes-base.mySql",
|
"type": "n8n-nodes-base.mySql",
|
||||||
"typeVersion": 2.3,
|
"typeVersion": 2.3,
|
||||||
"position": [1340, 400],
|
"position": [1560, 400],
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"mySql": {
|
"mySql": {
|
||||||
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
"id": "YOUR_MYSQL_CREDENTIAL_ID",
|
||||||
@@ -172,7 +196,7 @@
|
|||||||
"name": "Stop and Error",
|
"name": "Stop and Error",
|
||||||
"type": "n8n-nodes-base.stopAndError",
|
"type": "n8n-nodes-base.stopAndError",
|
||||||
"typeVersion": 1,
|
"typeVersion": 1,
|
||||||
"position": [1560, 400]
|
"position": [1780, 400]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {},
|
"parameters": {},
|
||||||
@@ -180,11 +204,33 @@
|
|||||||
"name": "Export Complete",
|
"name": "Export Complete",
|
||||||
"type": "n8n-nodes-base.noOp",
|
"type": "n8n-nodes-base.noOp",
|
||||||
"typeVersion": 1,
|
"typeVersion": 1,
|
||||||
"position": [1780, 200]
|
"position": [2000, 200]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"Hourly Trigger": {
|
"Webhook Trigger": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Process Trigger",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Daily Backup Trigger": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Process Trigger",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Process Trigger": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -295,9 +341,14 @@
|
|||||||
"name": "bunnycdn",
|
"name": "bunnycdn",
|
||||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
"updatedAt": "2024-01-01T00:00:00.000Z"
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "webhook",
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"triggerCount": 0,
|
"triggerCount": 0,
|
||||||
"updatedAt": "2024-01-01T00:00:00.000Z",
|
"updatedAt": "2024-01-01T00:00:00.000Z",
|
||||||
"versionId": "1"
|
"versionId": "2"
|
||||||
}
|
}
|
||||||
|
|||||||
234
webapp/api.php
234
webapp/api.php
@@ -86,6 +86,30 @@ try {
|
|||||||
handleShortnamesList($db);
|
handleShortnamesList($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'webhook_settings_get':
|
||||||
|
handleWebhookSettingsGet($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webhook_settings_save':
|
||||||
|
handleWebhookSettingsSave($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webhook_test':
|
||||||
|
handleWebhookTest($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webhook_trigger':
|
||||||
|
handleWebhookTrigger($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webhook_process':
|
||||||
|
handleWebhookProcess($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webhook_queue_status':
|
||||||
|
handleWebhookQueueStatus($db);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonResponse(['error' => 'Invalid action'], 400);
|
jsonResponse(['error' => 'Invalid action'], 400);
|
||||||
}
|
}
|
||||||
@@ -245,6 +269,9 @@ function handleCreate($db) {
|
|||||||
// Log the action
|
// Log the action
|
||||||
logAction($db, $id, 'INSERT', null, $input);
|
logAction($db, $id, 'INSERT', null, $input);
|
||||||
|
|
||||||
|
// Queue webhook notification
|
||||||
|
queueWebhookNotification($db, 'entry_created', 1);
|
||||||
|
|
||||||
jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201);
|
jsonResponse(['success' => true, 'id' => $id, 'message' => 'Entry created successfully'], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +357,9 @@ function handleUpdate($db) {
|
|||||||
// Log the action
|
// Log the action
|
||||||
logAction($db, $id, 'UPDATE', $oldEntry, $input);
|
logAction($db, $id, 'UPDATE', $oldEntry, $input);
|
||||||
|
|
||||||
|
// Queue webhook notification
|
||||||
|
queueWebhookNotification($db, 'entry_updated', 1);
|
||||||
|
|
||||||
jsonResponse(['success' => true, 'message' => 'Entry updated successfully']);
|
jsonResponse(['success' => true, 'message' => 'Entry updated successfully']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +399,9 @@ function handleDelete($db) {
|
|||||||
// Log the action
|
// Log the action
|
||||||
logAction($db, $id, 'DELETE', $oldEntry, null);
|
logAction($db, $id, 'DELETE', $oldEntry, null);
|
||||||
|
|
||||||
|
// Queue webhook notification
|
||||||
|
queueWebhookNotification($db, 'entry_deleted', 1);
|
||||||
|
|
||||||
jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']);
|
jsonResponse(['success' => true, 'message' => 'Entry deleted successfully']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +604,12 @@ function handleImport($db) {
|
|||||||
'failed' => $failed
|
'failed' => $failed
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Queue webhook notification for bulk import
|
||||||
|
$totalAffected = $inserted + $updated;
|
||||||
|
if ($totalAffected > 0) {
|
||||||
|
queueWebhookNotification($db, 'bulk_import', $totalAffected);
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse([
|
jsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'inserted' => $inserted,
|
'inserted' => $inserted,
|
||||||
@@ -724,6 +763,12 @@ function handleImportUrl($db) {
|
|||||||
'failed' => $failed
|
'failed' => $failed
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Queue webhook notification for URL import
|
||||||
|
$totalAffected = $inserted + $updated;
|
||||||
|
if ($totalAffected > 0) {
|
||||||
|
queueWebhookNotification($db, 'url_import', $totalAffected);
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse([
|
jsonResponse([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'inserted' => $inserted,
|
'inserted' => $inserted,
|
||||||
@@ -766,6 +811,11 @@ function handleClearAll($db) {
|
|||||||
// Log the action
|
// Log the action
|
||||||
logAction($db, null, 'DELETE', ['count' => $count], ['type' => 'clear_all']);
|
logAction($db, null, 'DELETE', ['count' => $count], ['type' => 'clear_all']);
|
||||||
|
|
||||||
|
// Queue webhook notification for clear all
|
||||||
|
if ($count > 0) {
|
||||||
|
queueWebhookNotification($db, 'clear_all', $count);
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(['success' => true, 'deleted' => $count]);
|
jsonResponse(['success' => true, 'deleted' => $count]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -934,3 +984,187 @@ function handleShortnamesList($db) {
|
|||||||
|
|
||||||
jsonResponse(['success' => true, 'data' => $shortnames]);
|
jsonResponse(['success' => true, 'data' => $shortnames]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhook settings
|
||||||
|
*/
|
||||||
|
function handleWebhookSettingsGet($db) {
|
||||||
|
$settings = [
|
||||||
|
'webhook_url' => getSetting($db, 'n8n_webhook_url', ''),
|
||||||
|
'webhook_enabled' => getSetting($db, 'n8n_webhook_enabled', '0') === '1',
|
||||||
|
'webhook_delay_minutes' => intval(getSetting($db, 'n8n_webhook_delay_minutes', '3'))
|
||||||
|
];
|
||||||
|
|
||||||
|
jsonResponse(['success' => true, 'data' => $settings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save webhook settings
|
||||||
|
*/
|
||||||
|
function handleWebhookSettingsSave($db) {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate CSRF
|
||||||
|
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||||
|
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhookUrl = trim($input['webhook_url'] ?? '');
|
||||||
|
$webhookEnabled = !empty($input['webhook_enabled']) ? '1' : '0';
|
||||||
|
$delayMinutes = max(1, min(60, intval($input['webhook_delay_minutes'] ?? 3)));
|
||||||
|
|
||||||
|
// Validate URL if provided
|
||||||
|
if (!empty($webhookUrl) && !filter_var($webhookUrl, FILTER_VALIDATE_URL)) {
|
||||||
|
jsonResponse(['error' => 'Invalid webhook URL'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSetting($db, 'n8n_webhook_url', $webhookUrl);
|
||||||
|
saveSetting($db, 'n8n_webhook_enabled', $webhookEnabled);
|
||||||
|
saveSetting($db, 'n8n_webhook_delay_minutes', (string)$delayMinutes);
|
||||||
|
|
||||||
|
jsonResponse(['success' => true, 'message' => 'Webhook settings saved successfully']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test webhook connection
|
||||||
|
*/
|
||||||
|
function handleWebhookTest($db) {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate CSRF
|
||||||
|
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||||
|
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
jsonResponse(['error' => 'No webhook URL configured'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'event' => 'test',
|
||||||
|
'message' => 'Test webhook from Geofeed Manager',
|
||||||
|
'timestamp' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = sendWebhook($webhookUrl, $payload);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
jsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Webhook test successful',
|
||||||
|
'http_code' => $result['http_code']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
jsonResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Webhook test failed: ' . ($result['error'] ?: "HTTP {$result['http_code']}"),
|
||||||
|
'http_code' => $result['http_code']
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger webhook (immediate)
|
||||||
|
*/
|
||||||
|
function handleWebhookTrigger($db) {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate CSRF
|
||||||
|
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||||
|
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = triggerImmediateWebhook($db, 'manual_trigger');
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
jsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Webhook triggered successfully',
|
||||||
|
'http_code' => $result['http_code']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
jsonResponse([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error'] ?: 'Failed to trigger webhook',
|
||||||
|
'http_code' => $result['http_code'] ?? null
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending webhooks in the queue
|
||||||
|
* This endpoint can be called by a cron job or manually
|
||||||
|
*/
|
||||||
|
function handleWebhookProcess($db) {
|
||||||
|
// This endpoint can be called without CSRF for cron jobs
|
||||||
|
// but we'll check for an optional API key in the future
|
||||||
|
|
||||||
|
$result = processWebhookQueue($db);
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'processed' => $result['processed'],
|
||||||
|
'results' => $result['results'] ?? []
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhook queue status
|
||||||
|
*/
|
||||||
|
function handleWebhookQueueStatus($db) {
|
||||||
|
// Get pending webhooks
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT id, trigger_reason, entries_affected, queued_at, scheduled_for, status
|
||||||
|
FROM webhook_queue
|
||||||
|
WHERE status IN ('pending', 'processing')
|
||||||
|
ORDER BY scheduled_for ASC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$pending = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get recent completed/failed webhooks
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT id, trigger_reason, entries_affected, queued_at, processed_at, status, response_code
|
||||||
|
FROM webhook_queue
|
||||||
|
WHERE status IN ('completed', 'failed')
|
||||||
|
ORDER BY processed_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$recent = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get counts
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||||
|
SUM(CASE WHEN status = 'completed' AND processed_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 ELSE 0 END) as completed_24h,
|
||||||
|
SUM(CASE WHEN status = 'failed' AND processed_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 ELSE 0 END) as failed_24h
|
||||||
|
FROM webhook_queue
|
||||||
|
");
|
||||||
|
$counts = $stmt->fetch();
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'pending' => $pending,
|
||||||
|
'recent' => $recent,
|
||||||
|
'counts' => [
|
||||||
|
'pending' => intval($counts['pending_count'] ?? 0),
|
||||||
|
'completed_24h' => intval($counts['completed_24h'] ?? 0),
|
||||||
|
'failed_24h' => intval($counts['failed_24h'] ?? 0)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,3 +107,207 @@ function isValidRegionCode($code) {
|
|||||||
if (empty($code)) return true;
|
if (empty($code)) return true;
|
||||||
return preg_match('/^[A-Z]{2}-[A-Z0-9]{1,3}$/i', $code);
|
return preg_match('/^[A-Z]{2}-[A-Z0-9]{1,3}$/i', $code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get a setting value from the database
|
||||||
|
function getSetting($db, $key, $default = null) {
|
||||||
|
static $cache = [];
|
||||||
|
|
||||||
|
if (isset($cache[$key])) {
|
||||||
|
return $cache[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT setting_value FROM geofeed_settings WHERE setting_key = :key");
|
||||||
|
$stmt->execute([':key' => $key]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
$cache[$key] = $result ? $result['setting_value'] : $default;
|
||||||
|
return $cache[$key];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a setting value to the database
|
||||||
|
function saveSetting($db, $key, $value) {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO geofeed_settings (setting_key, setting_value)
|
||||||
|
VALUES (:key, :value)
|
||||||
|
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = CURRENT_TIMESTAMP
|
||||||
|
");
|
||||||
|
$stmt->execute([':key' => $key, ':value' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a webhook notification with debouncing
|
||||||
|
* This will schedule a webhook to fire after a delay, consolidating multiple updates
|
||||||
|
*/
|
||||||
|
function queueWebhookNotification($db, $reason = 'manual', $entriesAffected = 1) {
|
||||||
|
// Check if webhooks are enabled
|
||||||
|
$enabled = getSetting($db, 'n8n_webhook_enabled', '0');
|
||||||
|
if ($enabled !== '1') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delayMinutes = intval(getSetting($db, 'n8n_webhook_delay_minutes', '3'));
|
||||||
|
$scheduledFor = date('Y-m-d H:i:s', strtotime("+{$delayMinutes} minutes"));
|
||||||
|
|
||||||
|
// Check if there's already a pending webhook scheduled
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, entries_affected FROM webhook_queue
|
||||||
|
WHERE status = 'pending' AND scheduled_for > NOW()
|
||||||
|
ORDER BY scheduled_for DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update existing pending webhook to consolidate and reschedule
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE webhook_queue
|
||||||
|
SET scheduled_for = :scheduled_for,
|
||||||
|
entries_affected = entries_affected + :entries,
|
||||||
|
trigger_reason = CONCAT(IFNULL(trigger_reason, ''), ', ', :reason)
|
||||||
|
WHERE id = :id
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
':scheduled_for' => $scheduledFor,
|
||||||
|
':entries' => $entriesAffected,
|
||||||
|
':reason' => $reason,
|
||||||
|
':id' => $existing['id']
|
||||||
|
]);
|
||||||
|
return $existing['id'];
|
||||||
|
} else {
|
||||||
|
// Create new webhook queue entry
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO webhook_queue (webhook_type, trigger_reason, entries_affected, scheduled_for, status)
|
||||||
|
VALUES ('geofeed_update', :reason, :entries, :scheduled_for, 'pending')
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
':reason' => $reason,
|
||||||
|
':entries' => $entriesAffected,
|
||||||
|
':scheduled_for' => $scheduledFor
|
||||||
|
]);
|
||||||
|
return $db->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending webhooks that are due
|
||||||
|
* This should be called by a cron job or the webhook processor endpoint
|
||||||
|
*/
|
||||||
|
function processWebhookQueue($db) {
|
||||||
|
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
return ['processed' => 0, 'error' => 'No webhook URL configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending webhooks that are due
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT * FROM webhook_queue
|
||||||
|
WHERE status = 'pending' AND scheduled_for <= NOW()
|
||||||
|
ORDER BY scheduled_for ASC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$webhooks = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($webhooks as $webhook) {
|
||||||
|
// Mark as processing
|
||||||
|
$updateStmt = $db->prepare("UPDATE webhook_queue SET status = 'processing' WHERE id = :id");
|
||||||
|
$updateStmt->execute([':id' => $webhook['id']]);
|
||||||
|
|
||||||
|
// Send the webhook
|
||||||
|
$payload = [
|
||||||
|
'event' => 'geofeed_update',
|
||||||
|
'queue_id' => $webhook['id'],
|
||||||
|
'trigger_reason' => $webhook['trigger_reason'],
|
||||||
|
'entries_affected' => $webhook['entries_affected'],
|
||||||
|
'queued_at' => $webhook['queued_at'],
|
||||||
|
'timestamp' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = sendWebhook($webhookUrl, $payload);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
$finalStatus = $result['success'] ? 'completed' : 'failed';
|
||||||
|
$updateStmt = $db->prepare("
|
||||||
|
UPDATE webhook_queue
|
||||||
|
SET status = :status, processed_at = NOW(), response_code = :code, response_body = :body
|
||||||
|
WHERE id = :id
|
||||||
|
");
|
||||||
|
$updateStmt->execute([
|
||||||
|
':status' => $finalStatus,
|
||||||
|
':code' => $result['http_code'],
|
||||||
|
':body' => substr($result['response'], 0, 1000),
|
||||||
|
':id' => $webhook['id']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
$results[] = [
|
||||||
|
'id' => $webhook['id'],
|
||||||
|
'success' => $result['success'],
|
||||||
|
'http_code' => $result['http_code']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['processed' => $processed, 'results' => $results];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a webhook to the configured URL
|
||||||
|
*/
|
||||||
|
function sendWebhook($url, $payload) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_URL => $url,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'User-Agent: Geofeed-Manager/1.0'
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $httpCode >= 200 && $httpCode < 300,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'response' => $response ?: $error,
|
||||||
|
'error' => $error
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger immediate webhook (bypasses queue for manual triggers)
|
||||||
|
*/
|
||||||
|
function triggerImmediateWebhook($db, $reason = 'manual_trigger') {
|
||||||
|
$webhookUrl = getSetting($db, 'n8n_webhook_url', '');
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
return ['success' => false, 'error' => 'No webhook URL configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'event' => 'geofeed_update',
|
||||||
|
'trigger_reason' => $reason,
|
||||||
|
'immediate' => true,
|
||||||
|
'timestamp' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
return sendWebhook($webhookUrl, $payload);
|
||||||
|
}
|
||||||
|
|||||||
253
webapp/index.php
253
webapp/index.php
@@ -1566,6 +1566,81 @@ if (!function_exists('generateCSRFToken')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Settings Section -->
|
||||||
|
<div class="advanced-section">
|
||||||
|
<h2 class="advanced-section-title">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
n8n Webhook Integration
|
||||||
|
</h2>
|
||||||
|
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
|
||||||
|
Enable Webhook Notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Webhook URL</label>
|
||||||
|
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
|
||||||
|
<div class="form-hint">The n8n webhook URL to receive notifications</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Debounce Delay (minutes)</label>
|
||||||
|
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
|
||||||
|
<div class="form-hint">Wait this many minutes after the last change before triggering the webhook (1-60 minutes)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||||
|
<button class="btn btn-primary" onclick="saveWebhookSettings()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||||
|
<polyline points="7 3 7 8 15 8"/>
|
||||||
|
</svg>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="testWebhook()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||||
|
</svg>
|
||||||
|
Trigger Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Queue Status -->
|
||||||
|
<div class="table-container" style="margin-top: 16px;">
|
||||||
|
<div class="table-header">
|
||||||
|
<h3 class="table-title">Webhook Queue</h3>
|
||||||
|
<div class="table-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="webhookQueueContainer" style="padding: 20px;">
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Client Logos Section -->
|
<!-- Client Logos Section -->
|
||||||
<div class="advanced-section">
|
<div class="advanced-section">
|
||||||
<h2 class="advanced-section-title">
|
<h2 class="advanced-section-title">
|
||||||
@@ -1850,6 +1925,8 @@ if (!function_exists('generateCSRFToken')) {
|
|||||||
loadAuditLog();
|
loadAuditLog();
|
||||||
loadShortnames();
|
loadShortnames();
|
||||||
loadLogosGrid();
|
loadLogosGrid();
|
||||||
|
loadWebhookSettings();
|
||||||
|
loadWebhookQueueStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2246,6 +2323,182 @@ if (!function_exists('generateCSRFToken')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load webhook settings
|
||||||
|
async function loadWebhookSettings() {
|
||||||
|
try {
|
||||||
|
const result = await api('webhook_settings_get');
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('webhookUrl').value = result.data.webhook_url || '';
|
||||||
|
document.getElementById('webhookEnabled').checked = result.data.webhook_enabled;
|
||||||
|
document.getElementById('webhookDelay').value = result.data.webhook_delay_minutes || 3;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load webhook settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save webhook settings
|
||||||
|
async function saveWebhookSettings() {
|
||||||
|
const webhookUrl = document.getElementById('webhookUrl').value.trim();
|
||||||
|
const webhookEnabled = document.getElementById('webhookEnabled').checked;
|
||||||
|
const webhookDelay = parseInt(document.getElementById('webhookDelay').value) || 3;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('webhook_settings_save', {}, 'POST', {
|
||||||
|
webhook_url: webhookUrl,
|
||||||
|
webhook_enabled: webhookEnabled,
|
||||||
|
webhook_delay_minutes: webhookDelay
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Webhook settings saved successfully', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(result.error || 'Failed to save settings', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test webhook connection
|
||||||
|
async function testWebhook() {
|
||||||
|
const webhookUrl = document.getElementById('webhookUrl').value.trim();
|
||||||
|
if (!webhookUrl) {
|
||||||
|
showToast('Please enter a webhook URL first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('Testing webhook...', 'success');
|
||||||
|
const result = await api('webhook_test', {}, 'POST', {});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`Webhook test successful (HTTP ${result.http_code})`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(result.error || 'Webhook test failed', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger webhook immediately
|
||||||
|
async function triggerWebhookNow() {
|
||||||
|
if (!confirm('This will immediately trigger the n8n webhook to update the CDN. Continue?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('webhook_trigger', {}, 'POST', {});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast('Webhook triggered successfully', 'success');
|
||||||
|
loadWebhookQueueStatus();
|
||||||
|
} else {
|
||||||
|
showToast(result.error || 'Failed to trigger webhook', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Network error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load webhook queue status
|
||||||
|
async function loadWebhookQueueStatus() {
|
||||||
|
const container = document.getElementById('webhookQueueContainer');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('webhook_queue_status');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
renderWebhookQueueStatus(result.data);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p style="color: var(--text-secondary);">Failed to load queue status</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<p style="color: var(--text-secondary);">Network error</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render webhook queue status
|
||||||
|
function renderWebhookQueueStatus(data) {
|
||||||
|
const container = document.getElementById('webhookQueueContainer');
|
||||||
|
|
||||||
|
// Stats row
|
||||||
|
let html = `
|
||||||
|
<div style="display: flex; gap: 24px; margin-bottom: 20px; flex-wrap: wrap;">
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 24px; font-weight: 700; color: var(--warning);">${data.counts.pending}</span>
|
||||||
|
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Pending</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 24px; font-weight: 700; color: var(--success);">${data.counts.completed_24h}</span>
|
||||||
|
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Completed (24h)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 24px; font-weight: 700; color: var(--error);">${data.counts.failed_24h}</span>
|
||||||
|
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Failed (24h)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pending webhooks
|
||||||
|
if (data.pending.length > 0) {
|
||||||
|
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Pending Webhooks</h4>';
|
||||||
|
html += '<div style="margin-bottom: 20px;">';
|
||||||
|
data.pending.forEach(item => {
|
||||||
|
const scheduledFor = new Date(item.scheduled_for);
|
||||||
|
const timeUntil = getTimeUntil(scheduledFor);
|
||||||
|
html += `
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--warning-bg); border-radius: 8px; margin-bottom: 8px;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Queued update')}</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries affected • fires ${timeUntil}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent webhooks
|
||||||
|
if (data.recent.length > 0) {
|
||||||
|
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Recent Webhooks</h4>';
|
||||||
|
data.recent.forEach(item => {
|
||||||
|
const isSuccess = item.status === 'completed';
|
||||||
|
const processedAt = item.processed_at ? getTimeAgo(new Date(item.processed_at)) : '-';
|
||||||
|
html += `
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: ${isSuccess ? 'var(--success-bg)' : 'var(--error-bg)'}; border-radius: 8px; margin-bottom: 8px;">
|
||||||
|
${isSuccess
|
||||||
|
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||||
|
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
||||||
|
}
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Update')}</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries • HTTP ${item.response_code || '-'} • ${processedAt}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.pending.length === 0 && data.recent.length === 0) {
|
||||||
|
html += '<p style="color: var(--text-secondary); font-size: 14px; text-align: center; padding: 20px 0;">No webhook activity yet</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get time until a future date
|
||||||
|
function getTimeUntil(date) {
|
||||||
|
const seconds = Math.floor((date - new Date()) / 1000);
|
||||||
|
if (seconds < 0) return 'now';
|
||||||
|
if (seconds < 60) return `in ${seconds}s`;
|
||||||
|
if (seconds < 3600) return `in ${Math.floor(seconds / 60)}m`;
|
||||||
|
return `in ${Math.floor(seconds / 3600)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
// Time ago helper
|
// Time ago helper
|
||||||
function getTimeAgo(date) {
|
function getTimeAgo(date) {
|
||||||
const seconds = Math.floor((new Date() - date) / 1000);
|
const seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
|||||||
Reference in New Issue
Block a user