6a8f374678
Operability: - Correlation IDs end-to-end: shared dispatch_id between log lines and EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths) and a new X-Request-Id middleware that normalizes inbound ids and binds request_id into log context. - dispatch_summary block merged into EventLog.details: per-target success/failure counts plus Telegram media delivered/skipped/failed and truncated error lists, so partial outcomes surface in the UI. - Diagnostic mode: admin can flip one module to DEBUG for a bounded window with auto-revert (in-memory only; setup_logging() resets on boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints plus DiagnosticsCassette UI on the settings page. Telegram: - Per-receiver options: disable_notification (silent send) and message_thread_id (forum-topic routing), wired through the dispatcher via a ContextVar so all four send sites (sendMessage / sendPhoto-Video- Document / sendMediaGroup / cache-hit POST) pick them up. - send_large_videos_as_documents target setting: bypass the 50 MB sendVideo cap by falling back to sendDocument for oversized videos. - sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES, 45 MB) with per-item fallback on chunk failure so a stale file_id no longer silently drops a cached asset. Tests: - New: diagnostic_mode, dispatch_summary, request_correlation, telegram_media_group_partial, telegram_per_send_options. Docs: - .claude/reviews/: six-axis production-readiness review of v0.8.1. - .claude/docs/functional-review-2026-05-28.md: focused review of Telegram/Immich/logging subsystems.
425 lines
12 KiB
Svelte
425 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
|
|
interface ActiveOverride {
|
|
module: string;
|
|
baseline_level: string;
|
|
current_level: string;
|
|
activated_at: string;
|
|
expires_at: string;
|
|
remaining_seconds: number;
|
|
}
|
|
|
|
// Modules ship with shortcuts; users can also type a freeform name
|
|
// matching the backend allowlist (notify_bridge_*, sqlalchemy.*, etc.).
|
|
// Icons let the IconGridSelect render each entry as a visual chip
|
|
// instead of a bare text list — same pattern as the surrounding
|
|
// log-level / log-format selectors.
|
|
const QUICK_MODULES: { value: string; icon: string; label: string; desc?: string }[] = [
|
|
{ value: 'notify_bridge_core.notifications.telegram.client', icon: 'mdiSend', label: 'Telegram client' },
|
|
{ value: 'notify_bridge_core.notifications.dispatcher', icon: 'mdiCallSplit', label: 'Dispatcher' },
|
|
{ value: 'notify_bridge_core.providers.immich', icon: 'mdiImageMultiple', label: 'Immich provider' },
|
|
{ value: 'notify_bridge_server.services.watcher', icon: 'mdiEyeOutline', label: 'Watcher' },
|
|
{ value: 'notify_bridge_server.services.deferred_dispatch', icon: 'mdiClockOutline', label: 'Deferred dispatch' },
|
|
{ value: 'notify_bridge_server.services.scheduled_dispatch', icon: 'mdiCalendarClock', label: 'Scheduled dispatch' },
|
|
{ value: 'sqlalchemy.engine', icon: 'mdiDatabase', label: 'SQLAlchemy engine (SQL)' },
|
|
{ value: 'aiohttp.client', icon: 'mdiWeb', label: 'aiohttp client' },
|
|
];
|
|
|
|
const DURATION_PRESETS: { minutes: number; label: string }[] = [
|
|
{ minutes: 5, label: '5m' },
|
|
{ minutes: 15, label: '15m' },
|
|
{ minutes: 30, label: '30m' },
|
|
{ minutes: 60, label: '1h' },
|
|
{ minutes: 120, label: '2h' },
|
|
];
|
|
|
|
let active = $state<ActiveOverride[]>([]);
|
|
let pickedModule = $state(QUICK_MODULES[0].value);
|
|
let customModule = $state('');
|
|
let pickedMinutes = $state(30);
|
|
let submitting = $state(false);
|
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
// Resync from the backend every N seconds so a server-side auto-revert
|
|
// is reflected even if we missed a tick. Tracked as elapsed-time so the
|
|
// 1s ticker can drift without breaking the cadence.
|
|
const RESYNC_EVERY_SECONDS = 30;
|
|
let lastResyncAt = Date.now();
|
|
|
|
async function refresh(): Promise<void> {
|
|
try {
|
|
const data = await api<{ active: ActiveOverride[] }>(
|
|
'/settings/diagnostic-mode',
|
|
{ method: 'GET' },
|
|
);
|
|
active = data.active || [];
|
|
} catch (err: unknown) {
|
|
// Surface non-401 errors only; settings page already shows a banner
|
|
// when the API is unreachable.
|
|
}
|
|
}
|
|
|
|
function tick(): void {
|
|
// Cheap local countdown so the UI doesn't poll the server every second
|
|
// to render a clock. The full refresh happens every 30s OR on action.
|
|
if (active.length === 0) return;
|
|
const now = Date.now();
|
|
active = active
|
|
.map(a => ({
|
|
...a,
|
|
remaining_seconds: Math.max(
|
|
0,
|
|
Math.floor((new Date(a.expires_at).getTime() - now) / 1000),
|
|
),
|
|
}))
|
|
.filter(a => a.remaining_seconds > 0);
|
|
}
|
|
|
|
function startTicker(): void {
|
|
if (tickHandle != null) return;
|
|
tickHandle = setInterval(() => {
|
|
tick();
|
|
const now = Date.now();
|
|
if (now - lastResyncAt >= RESYNC_EVERY_SECONDS * 1000) {
|
|
lastResyncAt = now;
|
|
void refresh();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopTicker(): void {
|
|
if (tickHandle != null) {
|
|
clearInterval(tickHandle);
|
|
tickHandle = null;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
lastResyncAt = Date.now();
|
|
void refresh();
|
|
startTicker();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
stopTicker();
|
|
});
|
|
|
|
function effectiveModule(): string {
|
|
return (customModule.trim() || pickedModule).trim();
|
|
}
|
|
|
|
async function activate(): Promise<void> {
|
|
const mod = effectiveModule();
|
|
if (!mod) {
|
|
snackError(t('settings.diagModuleRequired'));
|
|
return;
|
|
}
|
|
submitting = true;
|
|
try {
|
|
const entry = await api<ActiveOverride>('/settings/diagnostic-mode', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ module: mod, duration_minutes: pickedMinutes }),
|
|
});
|
|
// Replace any existing row for this module with the new schedule.
|
|
active = [
|
|
...active.filter(a => a.module !== entry.module),
|
|
entry,
|
|
];
|
|
customModule = '';
|
|
snackSuccess(t('settings.diagActivated'));
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
snackError(msg || t('settings.diagActivateFailed'));
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
async function revert(module: string): Promise<void> {
|
|
try {
|
|
await api(`/settings/diagnostic-mode/${encodeURIComponent(module)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
active = active.filter(a => a.module !== module);
|
|
snackSuccess(t('settings.diagReverted'));
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
snackError(msg || t('settings.diagRevertFailed'));
|
|
}
|
|
}
|
|
|
|
function formatRemaining(seconds: number): string {
|
|
if (seconds <= 0) return '0s';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
if (mins >= 60) {
|
|
const hours = Math.floor(mins / 60);
|
|
const remMins = mins % 60;
|
|
return `${hours}h ${remMins}m`;
|
|
}
|
|
if (mins > 0) return `${mins}m ${secs}s`;
|
|
return `${secs}s`;
|
|
}
|
|
</script>
|
|
|
|
<section class="diag glass">
|
|
<header class="diag-head">
|
|
<div class="diag-eyebrow">
|
|
<MdiIcon name="mdiBugOutline" size={12} />
|
|
<span>{t('settings.diagnostics')}</span>
|
|
</div>
|
|
<h3 class="diag-title">{t('settings.diagnosticsHeadline')}</h3>
|
|
<p class="diag-sub">{t('settings.diagnosticsHint')}</p>
|
|
</header>
|
|
|
|
<!-- Compose new override -->
|
|
<div class="diag-compose">
|
|
<div class="diag-label">
|
|
<span>{t('settings.diagModuleQuick')}</span>
|
|
<IconGridSelect items={QUICK_MODULES} bind:value={pickedModule} columns={2} compact />
|
|
</div>
|
|
|
|
<label class="diag-label">
|
|
<span>{t('settings.diagModuleCustom')}</span>
|
|
<input
|
|
bind:value={customModule}
|
|
type="text"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder={t('settings.diagModuleCustomPlaceholder')}
|
|
class="diag-input"
|
|
/>
|
|
</label>
|
|
|
|
<div class="diag-label">
|
|
<span>{t('settings.diagDuration')}</span>
|
|
<div class="diag-duration-chips">
|
|
{#each DURATION_PRESETS as preset (preset.minutes)}
|
|
<button
|
|
type="button"
|
|
class="diag-chip"
|
|
class:diag-chip-active={pickedMinutes === preset.minutes}
|
|
onclick={() => (pickedMinutes = preset.minutes)}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={activate}
|
|
disabled={submitting}
|
|
class="diag-activate"
|
|
>
|
|
<MdiIcon name="mdiPlay" size={14} />
|
|
<span>{submitting ? t('common.loading') : t('settings.diagActivate')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Active overrides list -->
|
|
{#if active.length > 0}
|
|
<div class="diag-active" in:slide={{ duration: 180 }}>
|
|
<div class="diag-active-head">
|
|
<MdiIcon name="mdiTimerSandComplete" size={12} />
|
|
<span>{t('settings.diagActive')}</span>
|
|
</div>
|
|
{#each active as ov (ov.module)}
|
|
<div class="diag-row">
|
|
<div class="diag-row-info">
|
|
<code class="diag-row-module">{ov.module}</code>
|
|
<span class="diag-row-meta">
|
|
{t('settings.diagRevertsIn')} <strong>{formatRemaining(ov.remaining_seconds)}</strong>
|
|
<span class="diag-row-baseline">→ {ov.baseline_level}</span>
|
|
</span>
|
|
</div>
|
|
<IconButton
|
|
icon="mdiUndoVariant"
|
|
title={t('settings.diagRevertNow')}
|
|
onclick={() => revert(ov.module)}
|
|
size={16}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.diag {
|
|
padding: 1.5rem 1.6rem 1.4rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.15rem;
|
|
}
|
|
.diag-head {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.diag-eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.62rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
margin-bottom: 0.45rem;
|
|
}
|
|
.diag-title {
|
|
margin: 0;
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-weight: 400;
|
|
font-size: 1.15rem;
|
|
line-height: 1.35;
|
|
letter-spacing: -0.015em;
|
|
color: var(--color-foreground);
|
|
max-width: 38ch;
|
|
}
|
|
.diag-sub {
|
|
margin: 0.45rem 0 0 0;
|
|
font-size: 0.78rem;
|
|
color: var(--color-muted-foreground);
|
|
max-width: 56ch;
|
|
}
|
|
.diag-compose {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.7rem;
|
|
padding-top: 0.4rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.diag-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.32rem;
|
|
}
|
|
.diag-label > span {
|
|
font-size: 0.74rem;
|
|
font-weight: 500;
|
|
color: var(--color-foreground);
|
|
}
|
|
.diag-input {
|
|
width: 100%;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
padding: 0.45rem 0.7rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 8px;
|
|
background: var(--color-glass);
|
|
color: var(--color-foreground);
|
|
}
|
|
.diag-duration-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
}
|
|
.diag-chip {
|
|
padding: 0.32rem 0.75rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--color-border);
|
|
background: transparent;
|
|
color: var(--color-muted-foreground);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.diag-chip:hover {
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
}
|
|
.diag-chip-active {
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
color: var(--color-primary);
|
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
|
}
|
|
.diag-activate {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
align-self: flex-start;
|
|
padding: 0.55rem 1.1rem;
|
|
border-radius: 10px;
|
|
border: 1px solid color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
color: var(--color-primary);
|
|
font-family: var(--font-display);
|
|
font-style: italic;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.diag-activate:hover {
|
|
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-primary) 65%, var(--color-border));
|
|
}
|
|
.diag-activate:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.diag-active {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
padding-top: 0.55rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.diag-active-head {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.58rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.diag-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.6rem;
|
|
padding: 0.5rem 0.65rem;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-glass-strong);
|
|
}
|
|
.diag-row-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
min-width: 0;
|
|
}
|
|
.diag-row-module {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--color-foreground);
|
|
word-break: break-all;
|
|
}
|
|
.diag-row-meta {
|
|
font-size: 0.72rem;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.diag-row-baseline {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
margin-left: 0.4rem;
|
|
opacity: 0.7;
|
|
}
|
|
</style>
|