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:
@@ -133,6 +133,8 @@
|
|||||||
"authMode": "Authentication Mode",
|
"authMode": "Authentication Mode",
|
||||||
"authModeHint": "Choose hmac_sha256, bearer_token, or none",
|
"authModeHint": "Choose hmac_sha256, bearer_token, or none",
|
||||||
"genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.",
|
"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",
|
"webhookSecretRequired": "Webhook secret is required",
|
||||||
"apiToken": "API Token",
|
"apiToken": "API Token",
|
||||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||||
@@ -152,11 +154,29 @@
|
|||||||
"gpRefreshTokenKeep": "Refresh Token (leave empty to keep current)",
|
"gpRefreshTokenKeep": "Refresh Token (leave empty to keep current)",
|
||||||
"gpRefreshTokenHint": "Obtain from Google OAuth Playground (developers.google.com/oauthplayground) with the Photos Library API scope.",
|
"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",
|
"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",
|
"testAndSave": "Test & Save",
|
||||||
"saveWithoutTest": "Save without testing",
|
"saveWithoutTest": "Save without testing",
|
||||||
"selectType": "Select a provider type",
|
"selectType": "Select a provider type",
|
||||||
"testFailed": "Connection test failed"
|
"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": {
|
"notificationTracker": {
|
||||||
"title": "Notification Trackers",
|
"title": "Notification Trackers",
|
||||||
"description": "Monitor albums for changes",
|
"description": "Monitor albums for changes",
|
||||||
@@ -951,6 +971,20 @@
|
|||||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
||||||
"providerWebhook": "Receive events via HTTP POST"
|
"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": {
|
"error": {
|
||||||
"notFound": "Page not found",
|
"notFound": "Page not found",
|
||||||
"goHome": "Go home"
|
"goHome": "Go home"
|
||||||
|
|||||||
@@ -133,6 +133,8 @@
|
|||||||
"authMode": "Режим аутентификации",
|
"authMode": "Режим аутентификации",
|
||||||
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
|
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
|
||||||
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
|
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
|
||||||
|
"maxStoredPayloads": "Макс. сохранённых запросов",
|
||||||
|
"maxStoredPayloadsHint": "Количество сохраняемых запросов для отладки (0 = отключено, макс. 100)",
|
||||||
"webhookSecretRequired": "Секрет вебхука обязателен",
|
"webhookSecretRequired": "Секрет вебхука обязателен",
|
||||||
"apiToken": "API токен",
|
"apiToken": "API токен",
|
||||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||||
@@ -152,11 +154,29 @@
|
|||||||
"gpRefreshTokenKeep": "Refresh Token (оставьте пустым для сохранения текущего)",
|
"gpRefreshTokenKeep": "Refresh Token (оставьте пустым для сохранения текущего)",
|
||||||
"gpRefreshTokenHint": "Получите через Google OAuth Playground (developers.google.com/oauthplayground) с областью Photos Library API.",
|
"gpRefreshTokenHint": "Получите через Google OAuth Playground (developers.google.com/oauthplayground) с областью Photos Library API.",
|
||||||
"gpAllFieldsRequired": "Client ID, Client Secret и Refresh Token обязательны",
|
"gpAllFieldsRequired": "Client ID, Client Secret и Refresh Token обязательны",
|
||||||
|
"storePayloads": "Сохранять входящие данные",
|
||||||
|
"storePayloadsHint": "Сохранять тела недавних вебхук-запросов для отладки",
|
||||||
|
"maxStoredPayloads": "Макс. сохранённых запросов",
|
||||||
|
"maxStoredPayloadsHint": "Количество сохраняемых запросов (1-100)",
|
||||||
"testAndSave": "Проверить и сохранить",
|
"testAndSave": "Проверить и сохранить",
|
||||||
"saveWithoutTest": "Сохранить без проверки",
|
"saveWithoutTest": "Сохранить без проверки",
|
||||||
"selectType": "Выберите тип провайдера",
|
"selectType": "Выберите тип провайдера",
|
||||||
"testFailed": "Ошибка проверки подключения"
|
"testFailed": "Ошибка проверки подключения"
|
||||||
},
|
},
|
||||||
|
"webhookLogs": {
|
||||||
|
"title": "Последние запросы",
|
||||||
|
"empty": "Записей пока нет",
|
||||||
|
"clear": "Очистить историю",
|
||||||
|
"confirmClear": "Очистить все сохранённые запросы для этого провайдера?",
|
||||||
|
"statusMatched": "Совпадение",
|
||||||
|
"statusUnmatched": "Не совпало",
|
||||||
|
"statusError": "Ошибка",
|
||||||
|
"headers": "Заголовки",
|
||||||
|
"body": "Тело запроса",
|
||||||
|
"extractedFields": "Извлечённые поля",
|
||||||
|
"errorMessage": "Ошибка",
|
||||||
|
"cleared": "История запросов очищена"
|
||||||
|
},
|
||||||
"notificationTracker": {
|
"notificationTracker": {
|
||||||
"title": "Трекеры уведомлений",
|
"title": "Трекеры уведомлений",
|
||||||
"description": "Отслеживание изменений в альбомах",
|
"description": "Отслеживание изменений в альбомах",
|
||||||
@@ -951,6 +971,20 @@
|
|||||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||||
"providerWebhook": "Приём событий через HTTP POST"
|
"providerWebhook": "Приём событий через HTTP POST"
|
||||||
},
|
},
|
||||||
|
"webhookLogs": {
|
||||||
|
"title": "Последние запросы",
|
||||||
|
"empty": "Записей пока нет",
|
||||||
|
"clear": "Очистить историю",
|
||||||
|
"confirmClear": "Очистить все сохранённые запросы для этого провайдера?",
|
||||||
|
"statusMatched": "Совпадение",
|
||||||
|
"statusUnmatched": "Не совпало",
|
||||||
|
"statusError": "Ошибка",
|
||||||
|
"headers": "Заголовки",
|
||||||
|
"body": "Тело запроса",
|
||||||
|
"extractedFields": "Извлечённые поля",
|
||||||
|
"errorMessage": "Ошибка",
|
||||||
|
"cleared": "История запросов очищена"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"notFound": "Страница не найдена",
|
"notFound": "Страница не найдена",
|
||||||
"goHome": "На главную"
|
"goHome": "На главную"
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export interface ProviderDescriptor {
|
|||||||
// ── Webhook URL display ──
|
// ── Webhook URL display ──
|
||||||
/** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */
|
/** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */
|
||||||
webhookUrlPattern?: string;
|
webhookUrlPattern?: string;
|
||||||
|
/** Whether this provider stores incoming payload history for debugging. */
|
||||||
|
payloadHistory?: boolean;
|
||||||
|
|
||||||
// ── Provider-specific hooks ──
|
// ── Provider-specific hooks ──
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,12 +21,22 @@ export const webhookDescriptor: ProviderDescriptor = {
|
|||||||
type: 'password', optional: true,
|
type: 'password', optional: true,
|
||||||
hint: 'providers.genericWebhookSecretHint',
|
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) {
|
buildConfig(form, editing) {
|
||||||
const config: Record<string, any> = {
|
const config: Record<string, any> = {
|
||||||
auth_mode: form.auth_mode || 'none',
|
auth_mode: form.auth_mode || 'none',
|
||||||
payload_mappings: form.payload_mappings || [],
|
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.webhook_secret) config.webhook_secret = form.webhook_secret;
|
||||||
if (form.event_type_path) config.event_type_path = form.event_type_path;
|
if (form.event_type_path) config.event_type_path = form.event_type_path;
|
||||||
@@ -36,7 +46,9 @@ export const webhookDescriptor: ProviderDescriptor = {
|
|||||||
|
|
||||||
hasConfigChanged(form, existing) {
|
hasConfigChanged(form, existing) {
|
||||||
return form.auth_mode !== (existing.auth_mode || 'none') ||
|
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: [
|
eventFields: [
|
||||||
@@ -46,4 +58,5 @@ export const webhookDescriptor: ProviderDescriptor = {
|
|||||||
collectionMeta: null,
|
collectionMeta: null,
|
||||||
webhookBased: true,
|
webhookBased: true,
|
||||||
webhookUrlPattern: '/api/webhooks/webhook/{id}',
|
webhookUrlPattern: '/api/webhooks/webhook/{id}',
|
||||||
|
payloadHistory: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -181,6 +181,18 @@ export interface EventLog {
|
|||||||
created_at: string;
|
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 {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -59,19 +59,21 @@
|
|||||||
let openSearch: (() => void) | undefined;
|
let openSearch: (() => void) | undefined;
|
||||||
let pwdCurrent = $state('');
|
let pwdCurrent = $state('');
|
||||||
let pwdNew = $state('');
|
let pwdNew = $state('');
|
||||||
|
let pwdConfirm = $state('');
|
||||||
let pwdMsg = $state('');
|
let pwdMsg = $state('');
|
||||||
let pwdSuccess = $state(false);
|
let pwdSuccess = $state(false);
|
||||||
|
|
||||||
async function changePassword(e: SubmitEvent) {
|
async function changePassword(e: SubmitEvent) {
|
||||||
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||||
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
|
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
|
||||||
|
if (pwdNew !== pwdConfirm) { pwdMsg = t('auth.passwordMismatch'); return; }
|
||||||
try {
|
try {
|
||||||
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||||
pwdMsg = t('common.changePassword');
|
pwdMsg = t('common.changePassword');
|
||||||
pwdSuccess = true;
|
pwdSuccess = true;
|
||||||
pwdCurrent = ''; pwdNew = '';
|
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
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); }
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +607,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Password change modal -->
|
<!-- 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">
|
<form onsubmit={changePassword} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||||
@@ -614,7 +616,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
<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)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if pwdMsg}
|
{#if pwdMsg}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
||||||
import type { ServiceProvider } from '$lib/types';
|
import type { ServiceProvider } from '$lib/types';
|
||||||
|
|
||||||
let allProviders = $derived(providersCache.items);
|
let allProviders = $derived(providersCache.items);
|
||||||
@@ -263,6 +264,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{#if provDesc?.payloadHistory && !showForm}
|
||||||
|
<WebhookPayloadHistory providerId={provider.id} />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
||||||
|
/>
|
||||||
@@ -99,6 +99,8 @@ class WebhookProviderConfig(BaseModel):
|
|||||||
payload_mappings: list[PayloadMapping] = []
|
payload_mappings: list[PayloadMapping] = []
|
||||||
event_type_path: str | None = None
|
event_type_path: str | None = None
|
||||||
collection_path: str | None = None
|
collection_path: str | None = None
|
||||||
|
store_payloads: bool = True
|
||||||
|
max_stored_payloads: int = 20 # 1-100
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
|
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Webhook payload log API routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..auth.dependencies import get_current_user
|
||||||
|
from ..database.engine import get_session
|
||||||
|
from ..database.models import ServiceProvider, User, WebhookPayloadLog
|
||||||
|
from .helpers import get_owned_entity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/providers", tags=["webhook-logs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}/webhook-logs")
|
||||||
|
async def list_webhook_logs(
|
||||||
|
provider_id: int,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List recent webhook payload logs for a provider."""
|
||||||
|
provider = await get_owned_entity(
|
||||||
|
session, ServiceProvider, provider_id, user.id,
|
||||||
|
not_found_msg="Provider not found",
|
||||||
|
)
|
||||||
|
if provider.type != "webhook":
|
||||||
|
raise HTTPException(status_code=400, detail="Not a webhook provider")
|
||||||
|
|
||||||
|
result = await session.exec(
|
||||||
|
select(WebhookPayloadLog)
|
||||||
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
.order_by(WebhookPayloadLog.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(min(limit, 100))
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"provider_id": log.provider_id,
|
||||||
|
"method": log.method,
|
||||||
|
"headers": log.headers,
|
||||||
|
"body": log.body,
|
||||||
|
"status": log.status,
|
||||||
|
"extracted_fields": log.extracted_fields,
|
||||||
|
"error_message": log.error_message,
|
||||||
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
|
}
|
||||||
|
for log in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{provider_id}/webhook-logs", status_code=204)
|
||||||
|
async def clear_webhook_logs(
|
||||||
|
provider_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Clear all webhook payload logs for a provider."""
|
||||||
|
await get_owned_entity(
|
||||||
|
session, ServiceProvider, provider_id, user.id,
|
||||||
|
not_found_msg="Provider not found",
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
sa_delete(WebhookPayloadLog)
|
||||||
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -18,10 +18,13 @@ from notify_bridge_core.providers.planka.event_parser import parse_webhook as pa
|
|||||||
from notify_bridge_core.providers.webhook.event_parser import parse_webhook as parse_generic_webhook
|
from notify_bridge_core.providers.webhook.event_parser import parse_webhook as parse_generic_webhook
|
||||||
|
|
||||||
from ..database.engine import get_engine
|
from ..database.engine import get_engine
|
||||||
|
from sqlalchemy import delete as sa_delete, func
|
||||||
|
|
||||||
from ..database.models import (
|
from ..database.models import (
|
||||||
EventLog,
|
EventLog,
|
||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
|
WebhookPayloadLog,
|
||||||
)
|
)
|
||||||
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
|
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
|
||||||
|
|
||||||
@@ -334,6 +337,62 @@ def _verify_generic_webhook_auth(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_headers(raw_headers: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Keep only safe headers for logging (no Authorization)."""
|
||||||
|
safe: dict[str, str] = {}
|
||||||
|
for k, v in raw_headers.items():
|
||||||
|
kl = k.lower()
|
||||||
|
if kl in ("content-type", "user-agent") or kl.startswith("x-"):
|
||||||
|
safe[k] = v
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_webhook_log(
|
||||||
|
session: AsyncSession,
|
||||||
|
provider_id: int,
|
||||||
|
method: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
body: dict[str, Any] | str,
|
||||||
|
status: str,
|
||||||
|
extracted_fields: dict[str, Any] | None = None,
|
||||||
|
error_message: str = "",
|
||||||
|
max_count: int = 20,
|
||||||
|
) -> None:
|
||||||
|
"""Insert a webhook payload log entry and prune old ones."""
|
||||||
|
try:
|
||||||
|
body_json = body if isinstance(body, dict) else {}
|
||||||
|
session.add(WebhookPayloadLog(
|
||||||
|
provider_id=provider_id,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
body=body_json,
|
||||||
|
status=status,
|
||||||
|
extracted_fields=extracted_fields or {},
|
||||||
|
error_message=error_message,
|
||||||
|
))
|
||||||
|
await session.flush()
|
||||||
|
count_result = await session.exec(
|
||||||
|
select(func.count(WebhookPayloadLog.id))
|
||||||
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
)
|
||||||
|
total = count_result.one()
|
||||||
|
if total > max_count:
|
||||||
|
oldest = await session.exec(
|
||||||
|
select(WebhookPayloadLog.id)
|
||||||
|
.where(WebhookPayloadLog.provider_id == provider_id)
|
||||||
|
.order_by(WebhookPayloadLog.created_at.asc())
|
||||||
|
.limit(total - max_count)
|
||||||
|
)
|
||||||
|
ids_to_delete = list(oldest.all())
|
||||||
|
if ids_to_delete:
|
||||||
|
await session.execute(
|
||||||
|
sa_delete(WebhookPayloadLog)
|
||||||
|
.where(WebhookPayloadLog.id.in_(ids_to_delete))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning("Failed to save webhook payload log for provider %d", provider_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook/{provider_id}")
|
@router.post("/webhook/{provider_id}")
|
||||||
async def generic_webhook(provider_id: int, request: Request):
|
async def generic_webhook(provider_id: int, request: Request):
|
||||||
"""Receive a generic webhook, extract variables via JSONPath, and dispatch notifications."""
|
"""Receive a generic webhook, extract variables via JSONPath, and dispatch notifications."""
|
||||||
@@ -348,6 +407,9 @@ async def generic_webhook(provider_id: int, request: Request):
|
|||||||
provider_config = provider.config or {}
|
provider_config = provider.config or {}
|
||||||
provider_name = provider.name
|
provider_name = provider.name
|
||||||
|
|
||||||
|
store_payloads = provider_config.get("store_payloads", True)
|
||||||
|
max_stored = min(max(int(provider_config.get("max_stored_payloads", 20)), 1), 100)
|
||||||
|
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
|
|
||||||
# Enforce payload size limit BEFORE parsing JSON
|
# Enforce payload size limit BEFORE parsing JSON
|
||||||
@@ -357,16 +419,32 @@ async def generic_webhook(provider_id: int, request: Request):
|
|||||||
if not _verify_generic_webhook_auth(provider_config, request, raw_body):
|
if not _verify_generic_webhook_auth(provider_config, request, raw_body):
|
||||||
raise HTTPException(status_code=403, detail="Authentication failed")
|
raise HTTPException(status_code=403, detail="Authentication failed")
|
||||||
|
|
||||||
|
safe_headers = _filter_headers(dict(request.headers))
|
||||||
|
|
||||||
# Parse JSON payload
|
# Parse JSON payload
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if store_payloads:
|
||||||
|
async with AsyncSession(get_engine()) as log_session:
|
||||||
|
await _save_webhook_log(
|
||||||
|
log_session, provider_id, request.method, safe_headers,
|
||||||
|
{}, "error", error_message="Invalid JSON", max_count=max_stored,
|
||||||
|
)
|
||||||
|
await log_session.commit()
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
# Parse via JSONPath mappings
|
# Parse via JSONPath mappings
|
||||||
req_headers = dict(request.headers)
|
req_headers = dict(request.headers)
|
||||||
event = parse_generic_webhook(payload, provider_name, provider_config, headers=req_headers)
|
event = parse_generic_webhook(payload, provider_name, provider_config, headers=req_headers)
|
||||||
if event is None:
|
if event is None:
|
||||||
|
if store_payloads:
|
||||||
|
async with AsyncSession(get_engine()) as log_session:
|
||||||
|
await _save_webhook_log(
|
||||||
|
log_session, provider_id, request.method, safe_headers,
|
||||||
|
payload, "unmatched", max_count=max_stored,
|
||||||
|
)
|
||||||
|
await log_session.commit()
|
||||||
return {"ok": True, "skipped": "parse failed"}
|
return {"ok": True, "skipped": "parse failed"}
|
||||||
|
|
||||||
# Inject source IP
|
# Inject source IP
|
||||||
@@ -427,6 +505,15 @@ async def generic_webhook(provider_id: int, request: Request):
|
|||||||
tracker.id, r.get("error", "unknown"),
|
tracker.id, r.get("error", "unknown"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log matched payload
|
||||||
|
if store_payloads:
|
||||||
|
await _save_webhook_log(
|
||||||
|
session, provider_id, request.method, safe_headers,
|
||||||
|
payload, "matched" if dispatched > 0 else "unmatched",
|
||||||
|
extracted_fields=dict(event.extra),
|
||||||
|
max_count=max_stored,
|
||||||
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|||||||
@@ -545,6 +545,22 @@ class ActionExecution(SQLModel, table=True):
|
|||||||
trigger: str = Field(default="scheduled") # "scheduled", "manual", "dry_run"
|
trigger: str = Field(default="scheduled") # "scheduled", "manual", "dry_run"
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookPayloadLog(SQLModel, table=True):
|
||||||
|
"""Log of incoming webhook payloads for debugging and replay."""
|
||||||
|
|
||||||
|
__tablename__ = "webhook_payload_log"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
provider_id: int = Field(foreign_key="service_provider.id", index=True)
|
||||||
|
method: str = Field(default="POST")
|
||||||
|
headers: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
body: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
status: str = Field(default="matched") # "matched" | "unmatched" | "error"
|
||||||
|
extracted_fields: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
|
error_message: str = Field(default="")
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(SQLModel, table=True):
|
class AppSetting(SQLModel, table=True):
|
||||||
"""Key-value app-level settings (admin-configurable)."""
|
"""Key-value app-level settings (admin-configurable)."""
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from .api.action_rules import router as action_rules_router
|
|||||||
from .api.action_types import router as action_types_router
|
from .api.action_types import router as action_types_router
|
||||||
from .commands.webhook import router as webhook_router, set_webhook_secret
|
from .commands.webhook import router as webhook_router, set_webhook_secret
|
||||||
from .api.webhooks import router as webhooks_router
|
from .api.webhooks import router as webhooks_router
|
||||||
|
from .api.webhook_logs import router as webhook_logs_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -141,6 +142,7 @@ app.include_router(command_trackers_router)
|
|||||||
app.include_router(command_template_configs_router)
|
app.include_router(command_template_configs_router)
|
||||||
app.include_router(webhook_router)
|
app.include_router(webhook_router)
|
||||||
app.include_router(webhooks_router)
|
app.include_router(webhooks_router)
|
||||||
|
app.include_router(webhook_logs_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
Reference in New Issue
Block a user