feat: add outbound webhook event framework

This commit is contained in:
Christian Krakau-Louis
2026-05-23 18:06:29 +02:00
parent aedd868a9c
commit d61a05bad0
17 changed files with 1674 additions and 14 deletions
+299
View File
@@ -633,6 +633,151 @@
{% endcall %}
{% endcall %}
<!-- ── Webhooks ──────────────────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Webhooks{% endcall %}
{% call card_description() %}Send signed operational events to downstream systems{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="space-y-5">
<form @submit.prevent="createWebhook()" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Name</span></label>
<input type="text" x-model="newWebhook.name" class="input input-bordered w-full" placeholder="Security automation" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Endpoint URL</span></label>
<input type="url" x-model="newWebhook.url" class="input input-bordered w-full" placeholder="https://example.com/dmarq/webhook" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Events</span></label>
<select x-model="newWebhook.eventType" class="input input-bordered w-full">
<option value="*">All events</option>
<template x-for="eventType in webhookEventTypes" :key="eventType">
<option :value="eventType" x-text="eventType"></option>
</template>
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Max Attempts</span></label>
<input type="number" x-model.number="newWebhook.max_attempts" class="input input-bordered w-full" min="1" max="10" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Timeout Seconds</span></label>
<input type="number" x-model.number="newWebhook.timeout_seconds" class="input input-bordered w-full" min="1" max="30" />
</div>
</div>
<div class="flex flex-col sm:flex-row justify-end gap-2">
<button type="button" class="btn btn-outline btn-md" :disabled="loadingWebhooks" @click="loadWebhooks()">
<template x-if="!loadingWebhooks">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
</svg>
</template>
<template x-if="loadingWebhooks"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Refresh
</button>
<button type="submit" class="btn btn-default btn-md" :disabled="savingWebhook">
<template x-if="!savingWebhook">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
</template>
<template x-if="savingWebhook"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Add Webhook
</button>
</div>
</form>
<template x-if="webhooks.length === 0">
<div class="alert alert-info">
<span>No outbound webhooks configured yet.</span>
</div>
</template>
<div class="overflow-x-auto rounded-md border border-border" x-show="webhooks.length > 0">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Events</th>
<th>Status</th>
<th>Last Result</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="hook in webhooks" :key="hook.id">
<tr>
<td class="font-medium" x-text="hook.name"></td>
<td class="font-mono text-xs" x-text="hook.url"></td>
<td class="text-xs" x-text="hook.event_types.join(', ')"></td>
<td><span class="badge" :class="hook.enabled ? 'badge-success' : 'badge-ghost'" x-text="hook.enabled ? 'Enabled' : 'Disabled'"></span></td>
<td class="text-xs text-muted-foreground" x-text="hook.last_success_at ? 'Success ' + new Date(hook.last_success_at).toLocaleString() : (hook.last_failure_at ? 'Failed ' + new Date(hook.last_failure_at).toLocaleString() : 'No deliveries')"></td>
<td class="text-right">
<button type="button" class="btn btn-outline btn-xs" :disabled="testingWebhookId === hook.id" @click="testWebhook(hook.id)">
<span x-show="testingWebhookId !== hook.id">Test</span>
<span x-show="testingWebhookId === hook.id" class="loading loading-spinner loading-xs"></span>
</button>
<button type="button" class="btn btn-ghost btn-xs" :disabled="disablingWebhookId === hook.id || !hook.enabled" @click="disableWebhook(hook.id)">
Disable
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="border-t border-border pt-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">Recent Deliveries</h3>
<div class="flex gap-2">
<button type="button" class="btn btn-outline btn-sm" :disabled="processingWebhooks" @click="processWebhooks()">
Process Due
</button>
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingWebhookDeliveries" @click="loadWebhookDeliveries()">
Refresh
</button>
</div>
</div>
<template x-if="webhookDeliveries.length === 0">
<div class="alert alert-info">
<span>No webhook deliveries recorded yet.</span>
</div>
</template>
<div class="space-y-2" x-show="webhookDeliveries.length > 0">
<template x-for="delivery in webhookDeliveries" :key="delivery.id">
<div class="rounded-md border border-border p-3">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-semibold" x-text="delivery.event_type"></div>
<div class="text-sm text-muted-foreground" x-text="delivery.last_error || delivery.response_excerpt || delivery.idempotency_key"></div>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="badge" :class="delivery.status === 'delivered' ? 'badge-success' : (delivery.status === 'failed' ? 'badge-error' : 'badge-warning')" x-text="delivery.status"></span>
<span class="badge badge-outline" x-text="delivery.attempt_count + '/' + delivery.max_attempts"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
{% endcall %}
{% endcall %}
<!-- ── Mail Sources shortcut ──────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
@@ -681,6 +826,22 @@ function settingsApp() {
importingCfZones: false,
cfZones: [],
showCfToken: false,
loadingWebhooks: false,
savingWebhook: false,
testingWebhookId: null,
disablingWebhookId: null,
processingWebhooks: false,
loadingWebhookDeliveries: false,
webhooks: [],
webhookDeliveries: [],
webhookEventTypes: [],
newWebhook: {
name: '',
url: '',
eventType: '*',
max_attempts: 5,
timeout_seconds: 10,
},
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
// No manual auth header needed for API calls from the UI.
@@ -705,6 +866,8 @@ function settingsApp() {
this.s = map;
await this.loadAlertHistory(false);
await this.loadConfigAudit(false);
await this.loadWebhooks(false);
await this.loadWebhookDeliveries(false);
} catch (err) {
this.showFlash('Error loading settings: ' + err.message, false);
}
@@ -951,6 +1114,142 @@ function settingsApp() {
}
},
async loadWebhooks(showMessage = true) {
this.loadingWebhooks = true;
try {
const res = await fetch('/api/v1/webhooks', { headers: this.apiHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (showMessage) this.showFlash('Webhook load failed: ' + (data.detail || res.statusText), false);
} else {
this.webhooks = data.endpoints || [];
this.webhookEventTypes = data.supported_event_types || [];
if (showMessage) this.showFlash('Webhooks refreshed.', true);
}
} catch (err) {
if (showMessage) this.showFlash('Error loading webhooks: ' + err.message, false);
} finally {
this.loadingWebhooks = false;
}
},
async createWebhook() {
this.savingWebhook = true;
try {
const payload = {
name: this.newWebhook.name,
url: this.newWebhook.url,
event_types: [this.newWebhook.eventType || '*'],
max_attempts: Number(this.newWebhook.max_attempts || 5),
timeout_seconds: Number(this.newWebhook.timeout_seconds || 10),
enabled: true,
};
const res = await fetch('/api/v1/webhooks', {
method: 'POST',
headers: this.apiHeaders(),
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Webhook create failed: ' + (data.detail || res.statusText), false);
} else {
this.newWebhook = { name: '', url: '', eventType: '*', max_attempts: 5, timeout_seconds: 10 };
await this.loadWebhooks(false);
this.showFlash('Webhook created. Signing secret was generated and stored securely.', true);
}
} catch (err) {
this.showFlash('Error creating webhook: ' + err.message, false);
} finally {
this.savingWebhook = false;
}
},
async testWebhook(endpointId) {
this.testingWebhookId = endpointId;
try {
const res = await fetch(`/api/v1/webhooks/${endpointId}/test`, {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Webhook test failed: ' + (data.detail || res.statusText), false);
} else {
await this.loadWebhooks(false);
await this.loadWebhookDeliveries(false);
const status = data.delivery ? data.delivery.status : 'queued';
this.showFlash(`Webhook test ${status}.`, status === 'delivered');
}
} catch (err) {
this.showFlash('Error testing webhook: ' + err.message, false);
} finally {
this.testingWebhookId = null;
}
},
async disableWebhook(endpointId) {
this.disablingWebhookId = endpointId;
try {
const res = await fetch(`/api/v1/webhooks/${endpointId}`, {
method: 'DELETE',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Webhook disable failed: ' + (data.detail || res.statusText), false);
} else {
await this.loadWebhooks(false);
this.showFlash('Webhook disabled.', true);
}
} catch (err) {
this.showFlash('Error disabling webhook: ' + err.message, false);
} finally {
this.disablingWebhookId = null;
}
},
async loadWebhookDeliveries(showMessage = true) {
this.loadingWebhookDeliveries = true;
try {
const res = await fetch('/api/v1/webhooks/deliveries?limit=10', {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (showMessage) this.showFlash('Delivery history failed: ' + (data.detail || res.statusText), false);
} else {
this.webhookDeliveries = data.deliveries || [];
if (showMessage) this.showFlash('Webhook deliveries refreshed.', true);
}
} catch (err) {
if (showMessage) this.showFlash('Error loading webhook deliveries: ' + err.message, false);
} finally {
this.loadingWebhookDeliveries = false;
}
},
async processWebhooks() {
this.processingWebhooks = true;
try {
const res = await fetch('/api/v1/webhooks/deliveries/process', {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Webhook processing failed: ' + (data.detail || res.statusText), false);
} else {
await this.loadWebhooks(false);
await this.loadWebhookDeliveries(false);
this.showFlash(`${(data.deliveries || []).length} due webhook deliver${(data.deliveries || []).length === 1 ? 'y' : 'ies'} processed.`, true);
}
} catch (err) {
this.showFlash('Error processing webhooks: ' + err.message, false);
} finally {
this.processingWebhooks = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;