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:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user