feat(webhook): inbound delivery audit log
Build / build (push) Successful in 10m35s

Persists every inbound webhook hit (project + site) so users can debug
"why didn't my deploy fire?" without grepping daemon logs. Surfaces a
14-day rolling history under the WebhookPanel on each project + site
detail page; refreshes every 30s while open. Daily cron prunes records
older than 14 days alongside the existing event log prune.

Schema:
- webhook_deliveries(id, target_type, target_id, target_name, received_at,
  source_ip, signature_state, status_code, outcome, detail, body_size)
- indexes on (target_type,target_id,received_at) and (received_at)

Backend:
- store: WebhookDelivery model + Insert/List/Prune helpers
- webhook/handler: deferred recordDelivery() captures the final outcome
  on every return path including HMAC rejects, image mismatch, no-stage,
  auto_deploy=false, and successful deploys; signatureStateFor()
  classifies "unconfigured" vs "missing" vs "invalid" vs "valid"
- api: GET /api/{projects,sites}/{id}/webhook/deliveries with
  parseLimit() helper (default 50, max 200)
- main: daily prune cron retains the last 14 days

Frontend:
- WebhookDeliveryLog.svelte: panel with refresh button, status code +
  outcome + signature badges, relative time tooltip-on-hover for
  absolute time, source IP column
- Mounted below WebhookPanel on project + site detail pages
- en/ru i18n strings for outcome/signature enums and column labels
This commit is contained in:
2026-05-07 02:40:39 +03:00
parent 831b5c1a43
commit 0f60a7a5db
12 changed files with 591 additions and 16 deletions
+22
View File
@@ -376,6 +376,28 @@ export async function setStaticSiteRequireSignature(siteId: string, require: boo
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require });
}
export interface WebhookDelivery {
id: number;
target_type: 'project' | 'site';
target_id: string;
target_name: string;
received_at: string;
source_ip: string;
signature_state: 'valid' | 'invalid' | 'missing' | 'unconfigured';
status_code: number;
outcome: string;
detail: string;
body_size: number;
}
export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/projects/${projectId}/webhook/deliveries`, signal);
}
export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/sites/${siteId}/webhook/deliveries`, signal);
}
// ── Outgoing-webhook signing & test ────────────────────────────────
export interface NotificationSecretResponse {
@@ -0,0 +1,165 @@
<!--
WebhookDeliveryLog
Recent inbound webhook activity panel. Used on the project + site detail
pages so users can debug "why didn't my deploy fire?" without grepping
daemon logs. Polls the audit table every 30s while the panel is mounted.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { WebhookDelivery } from '$lib/api';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
import { IconRefresh, IconLoader } from '$lib/components/icons';
interface Props {
fetchDeliveries: (signal?: AbortSignal) => Promise<WebhookDelivery[]>;
}
const { fetchDeliveries }: Props = $props();
let deliveries = $state<WebhookDelivery[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let error = $state('');
let controller = new AbortController();
let interval: ReturnType<typeof setInterval> | null = null;
async function load(refresh = false) {
controller.abort();
controller = new AbortController();
if (refresh) {
refreshing = true;
}
try {
deliveries = (await fetchDeliveries(controller.signal)) ?? [];
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('webhookLog.loadFailed');
} finally {
loading = false;
refreshing = false;
}
}
function outcomeBadge(outcome: string): string {
switch (outcome) {
case 'deploy':
return 'badge-success';
case 'rejected':
case 'error':
return 'badge-danger';
case 'bad_request':
case 'not_found':
return 'badge-warning';
default:
return 'badge-neutral';
}
}
function signatureBadge(state: string): string {
switch (state) {
case 'valid':
return 'badge-success';
case 'invalid':
return 'badge-danger';
case 'missing':
return 'badge-warning';
default:
return 'badge-neutral';
}
}
/** Backend returns naive (no-offset) timestamps — treat as UTC. */
function toUtcIso(s: string): string {
if (!s) return '';
return /Z|[+-]\d{2}:?\d{2}$/.test(s) ? s : s.replace(' ', 'T') + 'Z';
}
onMount(() => {
load();
interval = setInterval(() => load(), 30_000);
});
onDestroy(() => {
controller.abort();
if (interval) clearInterval(interval);
});
</script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('webhookLog.title')}</h2>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('webhookLog.description')}</p>
</div>
<button
type="button"
onclick={() => load(true)}
disabled={refreshing || loading}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
{#if refreshing}<IconLoader size={14} />{:else}<IconRefresh size={14} />{/if}
<span>{$t('webhookLog.refresh')}</span>
</button>
</div>
{#if loading}
<div class="space-y-2">
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
</div>
{:else if error}
<p class="text-sm text-[var(--color-danger)]">{error}</p>
{:else if deliveries.length === 0}
<p class="text-sm italic text-[var(--text-tertiary)]">{$t('webhookLog.empty')}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)] text-xs uppercase tracking-wide text-[var(--text-secondary)]">
<th class="px-3 py-2 text-left">{$t('webhookLog.colTime')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colStatus')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colOutcome')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colSignature')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colDetail')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colSource')}</th>
</tr>
</thead>
<tbody>
{#each deliveries as d (d.id)}
<tr class="border-b border-[var(--border-primary)] last:border-b-0 hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-3 py-2 whitespace-nowrap text-[var(--text-secondary)]" title={$fmt.dateTime(toUtcIso(d.received_at))}>
{$fmt.relative(toUtcIso(d.received_at))}
</td>
<td class="px-3 py-2 tabular-nums">
<span class="font-mono {d.status_code >= 500 ? 'text-[var(--color-danger)]' : d.status_code >= 400 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'}">
{d.status_code}
</span>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {outcomeBadge(d.outcome)}">
{$t(`webhookLog.outcome.${d.outcome}`) || d.outcome}
</span>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {signatureBadge(d.signature_state)}">
{$t(`webhookLog.sig.${d.signature_state}`) || d.signature_state}
</span>
</td>
<td class="px-3 py-2 text-[var(--text-secondary)]">
<span class="truncate" title={d.detail}>{d.detail}</span>
</td>
<td class="px-3 py-2 font-mono text-xs text-[var(--text-tertiary)]">
{d.source_ip}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+27
View File
@@ -1172,6 +1172,33 @@
"incoming": "Incoming webhooks",
"incomingMovedDesc": "Inbound webhooks are now scoped per entity. Open a project or static site to view and rotate its webhook URL."
},
"webhookLog": {
"title": "Recent webhook deliveries",
"description": "The last 14 days of inbound webhook hits — outcome, signature state, and reason. Refreshes every 30 seconds.",
"refresh": "Refresh",
"loadFailed": "Failed to load webhook deliveries",
"empty": "No webhook deliveries yet.",
"colTime": "When",
"colStatus": "Status",
"colOutcome": "Outcome",
"colSignature": "Signature",
"colDetail": "Detail",
"colSource": "Source",
"outcome": {
"deploy": "Deployed",
"skip": "Skipped",
"rejected": "Rejected",
"not_found": "Not found",
"bad_request": "Bad request",
"error": "Error"
},
"sig": {
"valid": "valid",
"invalid": "invalid",
"missing": "missing",
"unconfigured": "off"
}
},
"webhookPanel": {
"copy": "Copy",
"copied": "Webhook URL copied to clipboard",
+27
View File
@@ -1172,6 +1172,33 @@
"incoming": "Входящие вебхуки",
"incomingMovedDesc": "Входящие вебхуки теперь привязаны к конкретному проекту или сайту. Откройте страницу проекта или статического сайта, чтобы увидеть и перегенерировать URL."
},
"webhookLog": {
"title": "Последние доставки вебхуков",
"description": "Последние 14 дней входящих вебхуков — результат, состояние подписи и причина. Обновляется каждые 30 секунд.",
"refresh": "Обновить",
"loadFailed": "Не удалось загрузить журнал доставок",
"empty": "Пока нет доставок.",
"colTime": "Когда",
"colStatus": "Статус",
"colOutcome": "Результат",
"colSignature": "Подпись",
"colDetail": "Подробности",
"colSource": "Источник",
"outcome": {
"deploy": "Развёрнуто",
"skip": "Пропущено",
"rejected": "Отклонено",
"not_found": "Не найдено",
"bad_request": "Неверный запрос",
"error": "Ошибка"
},
"sig": {
"valid": "верна",
"invalid": "неверна",
"missing": "отсутствует",
"unconfigured": "выкл"
}
},
"webhookPanel": {
"copy": "Копировать",
"copied": "Webhook-URL скопирован в буфер обмена",
@@ -14,6 +14,7 @@
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types';
@@ -811,6 +812,9 @@
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
/>
<!-- Recent inbound webhook activity (debug + audit). -->
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
<OutgoingWebhookPanel
title={$t('projectDetail.outgoingWebhookTitle')}
+4
View File
@@ -10,6 +10,7 @@
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
@@ -317,6 +318,9 @@
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
/>
<!-- Recent inbound webhook activity (debug + audit). -->
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listStaticSiteWebhookDeliveries(siteId!, signal)} />
<!-- Outgoing notification URL (per-site override; falls through to global). -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>