Files
notify-bridge/frontend/src/routes/settings/DiagnosticsCassette.svelte
T
alexei.dolgolyov 6a8f374678 feat: observability, per-receiver Telegram options, oversized-video fallback
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.
2026-05-28 15:19:31 +03:00

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>