feat: webhook payload history — store and display recent incoming payloads

Backend:
- WebhookPayloadLog model (provider_id, method, headers, body, status, extracted_fields, error_message)
- Auto-log payloads in generic_webhook() with matched/unmatched/error status
- Auto-prune beyond max_stored_payloads per provider
- Header filtering (only Content-Type, User-Agent, X-* stored; no Authorization)
- GET/DELETE /api/providers/{id}/webhook-logs endpoints
- store_payloads + max_stored_payloads in WebhookProviderConfig

Frontend:
- WebhookPayloadHistory.svelte — expandable log viewer with status badges, JSON body, headers, extracted fields
- payloadHistory flag on webhook provider descriptor
- max_stored_payloads config field (0 = disabled)
- Password confirmation field on change password modal
- i18n keys for webhook logs (en + ru)
This commit is contained in:
2026-03-28 13:54:54 +03:00
parent c41182ffd0
commit 6113a0039c
13 changed files with 459 additions and 5 deletions
+34
View File
@@ -133,6 +133,8 @@
"authMode": "Authentication Mode",
"authModeHint": "Choose hmac_sha256, bearer_token, or none",
"genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.",
"maxStoredPayloads": "Max stored payloads",
"maxStoredPayloadsHint": "Number of recent payloads to keep for debugging (0 = disabled, max 100)",
"webhookSecretRequired": "Webhook secret is required",
"apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
@@ -152,11 +154,29 @@
"gpRefreshTokenKeep": "Refresh Token (leave empty to keep current)",
"gpRefreshTokenHint": "Obtain from Google OAuth Playground (developers.google.com/oauthplayground) with the Photos Library API scope.",
"gpAllFieldsRequired": "Client ID, Client Secret, and Refresh Token are all required",
"storePayloads": "Store incoming payloads",
"storePayloadsHint": "Save recent webhook request bodies for debugging",
"maxStoredPayloads": "Max stored payloads",
"maxStoredPayloadsHint": "Number of recent payloads to keep (1-100)",
"testAndSave": "Test & Save",
"saveWithoutTest": "Save without testing",
"selectType": "Select a provider type",
"testFailed": "Connection test failed"
},
"webhookLogs": {
"title": "Recent Payloads",
"empty": "No payloads recorded yet",
"clear": "Clear history",
"confirmClear": "Clear all stored payloads for this provider?",
"statusMatched": "Matched",
"statusUnmatched": "Unmatched",
"statusError": "Error",
"headers": "Headers",
"body": "Request Body",
"extractedFields": "Extracted Fields",
"errorMessage": "Error",
"cleared": "Payload history cleared"
},
"notificationTracker": {
"title": "Notification Trackers",
"description": "Monitor albums for changes",
@@ -951,6 +971,20 @@
"providerGooglePhotos": "Google Photos albums & shared libraries",
"providerWebhook": "Receive events via HTTP POST"
},
"webhookLogs": {
"title": "Recent Payloads",
"empty": "No payloads recorded yet",
"clear": "Clear history",
"confirmClear": "Clear all stored payloads for this provider?",
"statusMatched": "Matched",
"statusUnmatched": "Unmatched",
"statusError": "Error",
"headers": "Headers",
"body": "Request Body",
"extractedFields": "Extracted Fields",
"errorMessage": "Error",
"cleared": "Payload history cleared"
},
"error": {
"notFound": "Page not found",
"goHome": "Go home"
+34
View File
@@ -133,6 +133,8 @@
"authMode": "Режим аутентификации",
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
"maxStoredPayloads": "Макс. сохранённых запросов",
"maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)",
"webhookSecretRequired": "Секрет вебхука обязателен",
"apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
@@ -152,11 +154,29 @@
"gpRefreshTokenKeep": "Refresh Token (оставьте пустым для сохранения текущего)",
"gpRefreshTokenHint": "Получите через Google OAuth Playground (developers.google.com/oauthplayground) с областью Photos Library API.",
"gpAllFieldsRequired": "Client ID, Client Secret и Refresh Token обязательны",
"storePayloads": "Сохранять входящие данные",
"storePayloadsHint": "Сохранять тела недавних вебхук-запросов для отладки",
"maxStoredPayloads": "Макс. сохранённых запросов",
"maxStoredPayloadsHint": "Количество сохраняемых запросов (1-100)",
"testAndSave": "Проверить и сохранить",
"saveWithoutTest": "Сохранить без проверки",
"selectType": "Выберите тип провайдера",
"testFailed": "Ошибка проверки подключения"
},
"webhookLogs": {
"title": "Последние запросы",
"empty": "Записей пока нет",
"clear": "Очистить историю",
"confirmClear": "Очистить все сохранённые запросы для этого провайдера?",
"statusMatched": "Совпадение",
"statusUnmatched": "Не совпало",
"statusError": "Ошибка",
"headers": "Заголовки",
"body": "Тело запроса",
"extractedFields": "Извлечённые поля",
"errorMessage": "Ошибка",
"cleared": "История запросов очищена"
},
"notificationTracker": {
"title": "Трекеры уведомлений",
"description": "Отслеживание изменений в альбомах",
@@ -951,6 +971,20 @@
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
"providerWebhook": "Приём событий через HTTP POST"
},
"webhookLogs": {
"title": "Последние запросы",
"empty": "Записей пока нет",
"clear": "Очистить историю",
"confirmClear": "Очистить все сохранённые запросы для этого провайдера?",
"statusMatched": "Совпадение",
"statusUnmatched": "Не совпало",
"statusError": "Ошибка",
"headers": "Заголовки",
"body": "Тело запроса",
"extractedFields": "Извлечённые поля",
"errorMessage": "Ошибка",
"cleared": "История запросов очищена"
},
"error": {
"notFound": "Страница не найдена",
"goHome": "На главную"
+2
View File
@@ -139,6 +139,8 @@ export interface ProviderDescriptor {
// ── Webhook URL display ──
/** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */
webhookUrlPattern?: string;
/** Whether this provider stores incoming payload history for debugging. */
payloadHistory?: boolean;
// ── Provider-specific hooks ──
/**
+14 -1
View File
@@ -21,12 +21,22 @@ export const webhookDescriptor: ProviderDescriptor = {
type: 'password', optional: true,
hint: 'providers.genericWebhookSecretHint',
},
{
key: 'max_stored_payloads', configKey: 'max_stored_payloads',
label: 'providers.maxStoredPayloads',
type: 'number',
min: 0, max: 100,
defaultValue: 20,
hint: 'providers.maxStoredPayloadsHint',
},
],
buildConfig(form, editing) {
const config: Record<string, any> = {
auth_mode: form.auth_mode || 'none',
payload_mappings: form.payload_mappings || [],
store_payloads: form.store_payloads !== '0' && form.store_payloads !== 0,
max_stored_payloads: Math.max(1, Math.min(100, Number(form.max_stored_payloads) || 20)),
};
if (form.webhook_secret) config.webhook_secret = form.webhook_secret;
if (form.event_type_path) config.event_type_path = form.event_type_path;
@@ -36,7 +46,9 @@ export const webhookDescriptor: ProviderDescriptor = {
hasConfigChanged(form, existing) {
return form.auth_mode !== (existing.auth_mode || 'none') ||
!!form.webhook_secret;
!!form.webhook_secret ||
(form.store_payloads !== '0' && form.store_payloads !== 0) !== (existing.store_payloads !== false) ||
Number(form.max_stored_payloads || 20) !== Number(existing.max_stored_payloads || 20);
},
eventFields: [
@@ -46,4 +58,5 @@ export const webhookDescriptor: ProviderDescriptor = {
collectionMeta: null,
webhookBased: true,
webhookUrlPattern: '/api/webhooks/webhook/{id}',
payloadHistory: true,
};
+12
View File
@@ -181,6 +181,18 @@ export interface EventLog {
created_at: string;
}
export interface WebhookPayloadLog {
id: number;
provider_id: number;
method: string;
headers: Record<string, string>;
body: Record<string, any>;
status: 'matched' | 'unmatched' | 'error';
extracted_fields: Record<string, any>;
error_message: string;
created_at: string;
}
export interface User {
id: number;
username: string;
+11 -4
View File
@@ -59,19 +59,21 @@
let openSearch: (() => void) | undefined;
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdConfirm = $state('');
let pwdMsg = $state('');
let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
if (pwdNew !== pwdConfirm) { pwdMsg = t('auth.passwordMismatch'); return; }
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.changePassword');
pwdSuccess = true;
pwdCurrent = ''; pwdNew = '';
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
}
@@ -605,7 +607,7 @@
{/if}
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3">
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
@@ -614,7 +616,12 @@
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
@@ -20,6 +20,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
import type { ServiceProvider } from '$lib/types';
let allProviders = $derived(providersCache.items);
@@ -263,6 +264,9 @@
</div>
</div>
</Card>
{#if provDesc?.payloadHistory && !showForm}
<WebhookPayloadHistory providerId={provider.id} />
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,165 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Card from '$lib/components/Card.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import type { WebhookPayloadLog } from '$lib/types';
interface Props {
providerId: number;
}
let { providerId }: Props = $props();
let logs = $state<WebhookPayloadLog[]>([]);
let loading = $state(true);
let expandedId = $state<number | null>(null);
let showClearConfirm = $state(false);
async function loadLogs() {
loading = true;
try {
logs = await api(`/providers/${providerId}/webhook-logs`);
} catch {
logs = [];
}
loading = false;
}
async function clearLogs() {
try {
await api(`/providers/${providerId}/webhook-logs`, { method: 'DELETE' });
logs = [];
} catch { /* ignore */ }
showClearConfirm = false;
}
function toggleExpand(id: number) {
expandedId = expandedId === id ? null : id;
}
function statusColor(status: string): string {
if (status === 'matched') return '#059669';
if (status === 'unmatched') return '#f59e0b';
return '#ef4444';
}
function statusIcon(status: string): string {
if (status === 'matched') return 'mdiCheckCircle';
if (status === 'unmatched') return 'mdiMinusCircle';
return 'mdiAlertCircle';
}
function statusLabel(status: string): string {
if (status === 'matched') return t('webhookLogs.statusMatched');
if (status === 'unmatched') return t('webhookLogs.statusUnmatched');
return t('webhookLogs.statusError');
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString();
}
$effect(() => {
void providerId;
loadLogs();
});
</script>
<Card>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{t('webhookLogs.title')}</h3>
{#if logs.length > 0}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]">{logs.length}</span>
{/if}
</div>
{#if logs.length > 0}
<button
onclick={() => showClearConfirm = true}
class="text-xs px-2 py-1 rounded-md transition-colors hover:bg-[var(--color-error-bg)] text-[var(--color-error-fg)]"
>
{t('webhookLogs.clear')}
</button>
{/if}
</div>
{#if loading}
<div class="text-sm text-[var(--color-muted-foreground)] py-4 text-center">{t('common.loading')}</div>
{:else if logs.length === 0}
<div class="text-sm text-[var(--color-muted-foreground)] py-4 text-center">{t('webhookLogs.empty')}</div>
{:else}
<div class="space-y-1">
{#each logs as log}
<button
onclick={() => toggleExpand(log.id)}
class="w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--color-sidebar-active)]"
style="background: {expandedId === log.id ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<div class="flex items-center gap-2">
<MdiIcon name={expandedId === log.id ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
<span class="text-xs text-[var(--color-muted-foreground)] tabular-nums">{formatTime(log.created_at)}</span>
<span class="text-xs font-mono px-1 py-0.5 rounded bg-[var(--color-secondary)]">{log.method}</span>
<span class="flex items-center gap-1" style="color: {statusColor(log.status)};">
<MdiIcon name={statusIcon(log.status)} size={14} />
<span class="text-xs font-medium">{statusLabel(log.status)}</span>
</span>
{#if log.error_message && log.status === 'error'}
<span class="text-xs text-[var(--color-muted-foreground)] truncate max-w-[200px]">{log.error_message}</span>
{/if}
</div>
</button>
{#if expandedId === log.id}
<div class="ml-6 mr-2 mb-2 space-y-2">
<!-- Headers -->
{#if Object.keys(log.headers).length > 0}
<div>
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.headers')}</div>
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
{#each Object.entries(log.headers) as [key, value]}
<div><span class="text-[var(--color-primary)]">{key}</span>: {value}</div>
{/each}
</div>
</div>
{/if}
<!-- Body -->
<div>
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.body')}</div>
<pre class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all" style="max-height: 300px; overflow-y: auto;">{JSON.stringify(log.body, null, 2)}</pre>
</div>
<!-- Extracted fields -->
{#if log.status === 'matched' && Object.keys(log.extracted_fields).length > 0}
<div>
<div class="text-xs font-medium text-[var(--color-muted-foreground)] mb-1">{t('webhookLogs.extractedFields')}</div>
<div class="bg-[var(--color-background)] border border-[var(--color-border)] rounded-md p-2 text-xs font-mono">
{#each Object.entries(log.extracted_fields) as [key, value]}
<div><span class="text-[var(--color-primary)]">{key}</span>: {typeof value === 'object' ? JSON.stringify(value) : String(value)}</div>
{/each}
</div>
</div>
{/if}
<!-- Error message -->
{#if log.status === 'error' && log.error_message}
<div>
<div class="text-xs font-medium text-[var(--color-error-fg)] mb-1">{t('webhookLogs.errorMessage')}</div>
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] rounded-md p-2 text-xs">{log.error_message}</div>
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</Card>
<ConfirmModal
open={showClearConfirm}
title={t('webhookLogs.clear')}
message={t('webhookLogs.confirmClear')}
onconfirm={clearLogs}
oncancel={() => showClearConfirm = false}
/>