Compare commits

..

9 Commits

Author SHA1 Message Date
alexei.dolgolyov 5d41a39406 chore: release v0.7.2
Release / release (push) Successful in 1m10s
2026-05-11 00:39:06 +03:00
alexei.dolgolyov 6229bf9b74 feat(frontend): redesign settings/common with Aurora cassettes
Splits the monolithic settings page into 6 focused glass components matching
the polish of the recently redesigned settings/backup page.

- SettingsHero: PageHeader with 4 live status pills (URL host, timezone +
  ticking clock, locale codes, log severity tinted by level)
- IdentityCassette: groups External URL + Timezone + Locales as numbered
  rows; URL field gains copy + open chips and a mint border when valid
- TelegramCassette: webhook secret with show/hide toggle and verified
  status chip; cache TTL/max as oversized mono numerals with humanized
  previews ("720 hrs -> 30d")
- CacheLedger: mirrors BackupLedger -- big total, gradient capacity meter,
  tone-edged URL/Asset bucket rows colored by oldest entry age
- LoggingCassette: per-module overrides become tone-edged chips with
  severity-colored level borders; raw-text fallback behind toggle; live
  ACTIVE preview line
- SaveBar: sticky dirty-aware footer with citrus pulse, italic message,
  and Discard/Save (only renders when settings differ from baseline)

No backend changes -- same /settings and /settings/telegram-cache/* endpoints.
2026-05-11 00:15:30 +03:00
alexei.dolgolyov a666bad0c4 feat(frontend): group targets by bot, redesign backup settings
Targets page: collapse targets under a per-bot header (BotGroupHeader)
with a count chip and an "Open bot" cross-link. Receivers are hidden
by default and expand per group; non-bot types fall back to a "Direct
delivery" group. Telegram "Add receiver" now opens the EntitySelect
chat palette directly instead of an inline form — EntitySelect grew a
bindable `open` flag, `showTrigger`, and an `onclose` cancel signal.

Backup settings page: split the monolithic +page into focused panels
(BackupHero, BackupLedger, ExportPanel, ImportPanel, PendingStrip,
ScheduleCassette) and introduce a stepwise export/import flow with
category groups, secrets handling, conflict policy, and validation
gating. New i18n keys in both locales cover the bot grouping labels
and the backup step copy.
2026-05-10 23:51:48 +03:00
alexei.dolgolyov bede928a3f feat(server): add /status command handler for webhook providers
The generic-webhook provider has no upstream API, so /status reports
DB-derived stats: active/total trackers, provider name, and last event
timestamp (formatted via the shared get_last_event_str helper).

Includes pytest coverage for handler registration, populated stats with
a recent event, the empty-state dash sentinel, and unknown-command
fall-through. Template variable docs in command_template_configs.py
extended with the new trackers_active/trackers_total keys.
2026-05-10 23:51:25 +03:00
alexei.dolgolyov 87cb33cffe fix(frontend): stop event-log flicker on pagination
Pagination/filter reloads were collapsing the panel into a "Loading
events…" placeholder and then replaying the stagger entry animation,
which read as the whole section being reconstructed. Keep the existing
rows + paginator mounted during reload (with a soft dim) and only run
the aurora-rise cascade on the very first non-empty render.
2026-05-09 14:47:12 +03:00
alexei.dolgolyov 757271dadf chore: release v0.7.1
Release / release (push) Successful in 2m11s
2026-05-07 23:33:09 +03:00
alexei.dolgolyov 73b046f7a2 fix(frontend): cyrillic glyphs for nav and section labels
The legacy ``@fontsource/geist-sans`` (v5.2.5) ships latin only and
``@fontsource/geist-mono`` was imported with the default subset only —
so Russian text in the sidebar (nav links, section labels, badges) fell
back to system fonts (Segoe UI / Cascadia / Consolas) and visibly
clashed with the Latin glyphs around it.

- Switched the sans family to ``@fontsource-variable/geist`` (single
  variable woff2 with latin + latin-ext + cyrillic). Updated
  ``--font-sans`` to lead with ``'Geist Variable'`` then keep the old
  ``'Geist Sans'`` and system fallbacks for safety.
- Added ``@fontsource/geist-mono/cyrillic-{400,500,600}.css`` imports
  so Geist Mono renders Russian glyphs in the section labels and
  monospace badges instead of falling back.

Newsreader (display serif) still has no Cyrillic on fontsource, so
italic page-hero emphasis in Russian still falls back to Georgia.
That's a separate, less-prominent concern and a future swap.
2026-05-07 22:45:06 +03:00
alexei.dolgolyov b170c2b792 feat(frontend): smoother event refresh, localized crumbs, template config deep-link
- Auto-refresh ticker is now silent: skips ``eventsLoading`` so the
  loading placeholder no longer flashes, uses ``(event.id)`` key on
  the events ``{#each}`` so unchanged rows reuse their DOM nodes, and
  short-circuits the array reassignment when the visible page is
  identical to what we already rendered. No-op refreshes leave the
  list completely untouched.
- ``PageHeader`` crumbs (Routing · Notification, Operators · Bots, …)
  were hard-coded literals. Moved to a new ``crumbs`` i18n namespace
  with 9 keys; updated all 15 call sites to ``t('crumbs.*')`` so they
  switch with the language.
- Tracker form's Immich feature-discovery banner now exposes both
  ``Open Tracking Config`` and ``Open Template Config``. Added the
  ``?edit=<id>`` auto-open hook to ``/template-configs`` (mirrors the
  existing one on ``/tracking-configs``) so the new link lands users
  directly on the editor.
2026-05-07 22:34:24 +03:00
alexei.dolgolyov 35a3008896 feat: log bot command invocations to the event stream
Bot commands were the only user-initiated path that didn't surface in
the dashboard. They now produce ``command_handled`` /
``command_rate_limited`` / ``command_failed`` rows in ``EventLog``
alongside tracker and action events.

Backend
- ``EventLog`` gains nullable ``command_tracker_id`` / ``telegram_bot_id``
  FKs plus deletion-snapshot name columns (idempotent migration).
- New ``_log_command_event`` helper emits one row per invocation at the
  three branches in ``handle_command``. Logging failures are swallowed
  so they cannot block the user-visible reply.
- Telegram ``from`` is captured in poller and webhook, whitelisted to
  identity fields by ``_normalize_issuer`` (drops ``language_code`` and
  any future PII), persisted under ``details.issuer``.
- ``/api/status`` resolves live ``CommandTracker`` / ``TelegramBot``
  names (mirroring the action pattern) and exposes ``tracker_id``,
  ``command_tracker_id``, ``telegram_bot_id`` so the frontend can
  deep-link.

Frontend
- Event rows are now clickable and open a detail modal with full
  provenance (bot → chat → issuer → provider), raw ``details`` JSON,
  and per-entity action buttons.
- Buttons use the existing ``requestHighlight`` + ``goto`` crosslink
  pattern, so clicking lands on the entity's list page with that
  specific card scrolled into view and pulsing.
- Auto-refresh dropdown (Off / 10s / 30s / 1m / 5m) persisted in
  ``localStorage``; ticker pauses while the tab is hidden.
- Event-type filter, dashboard verb labels, and gradients extended for
  the three new ``command_*`` types.
- Filled in pre-existing missing i18n keys (``common.hide`` /
  ``common.show`` / ``commandConfig.noCommandsForProvider``).

Tests
- New ``test_command_event_logging.py`` covers subject formatting,
  issuer normalization, the three event branches, and graceful failure
  when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
2026-05-07 22:22:41 +03:00
54 changed files with 6066 additions and 815 deletions
+15 -30
View File
@@ -1,40 +1,25 @@
# v0.7.0 (2026-05-07)
# v0.7.2 (2026-05-11)
Hardened notification stack with shared HTTP base, SSRF protections, secret redaction, and a bounded queue across every provider client; Settings logging selectors switched to icon grids; entity names autogenerate from the chosen type or provider across bots, targets, trackers, actions, and configs.
## Features
## User-facing changes
- Redesign settings/common with Aurora cassettes — refreshed identity, logging, Telegram, and cache-ledger sections with the new glass/cassette UI ([6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9))
- Group targets by bot in the targets view and redesign backup settings ([a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad))
- Add `/status` command handler for webhook providers ([bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928))
### Features
## Bug Fixes
- Settings: replace log level and log format dropdowns with icon-grid selectors carrying per-option icons and i18n descriptions ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- Forms: auto-generate entity names from the selected type/provider across bots, targets, notification trackers, command trackers, actions, and tracking/template/command/command-template configs — names update live until you manually edit, then your edit is preserved ([5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2))
- Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
### Reliability & Security
- Notification stack hardening: shared HTTP base, SSRF protections, secret redaction in error logs, and a bounded delivery queue across the dispatcher, receiver, and all provider clients (telegram, discord, email, matrix, ntfy, slack, webhook) ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
## Development / Internal
### Refactoring
- Notifications: extract shared `http_base`, `redact`, and SSRF helpers; refactor dispatcher, queue, receiver, and every provider client onto the new base ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
### Tests
- New coverage: SSRF hardening, secret redaction, HTTP base, bounded queue, dispatcher aggregation, Telegram media partitioning, email and matrix clients ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
### Tooling
- Document code-review-graph MCP usage in CLAUDE.md, register `.mcp.json`, and gitignore `.code-review-graph/` ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
## All Commits
---
<details>
<summary>Click to expand</summary>
<summary>All Commits</summary>
| Hash | Message | Author |
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
| [0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a) | feat: harden notification stack and switch logging selectors to icon grid | alexei.dolgolyov |
| [5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2) | feat(frontend): autogenerate entity names from type/provider | alexei.dolgolyov |
| Hash | Message | Author |
|------|---------|--------|
| [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
| [a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad) | feat(frontend): group targets by bot, redesign backup settings | alexei.dolgolyov |
| [bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928) | feat(server): add /status command handler for webhook providers | alexei.dolgolyov |
| [87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c) | fix(frontend): stop event-log flicker on pagination | alexei.dolgolyov |
</details>
+16 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.7.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.7.1",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
@@ -14,6 +14,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
@@ -607,6 +608,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"node_modules/@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
},
"@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.7.0",
"version": "0.7.2",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -34,6 +34,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
+12 -6
View File
@@ -1,11 +1,17 @@
@import '@fontsource/geist-sans/300.css';
@import '@fontsource/geist-sans/400.css';
@import '@fontsource/geist-sans/500.css';
@import '@fontsource/geist-sans/600.css';
@import '@fontsource/geist-sans/700.css';
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
so RU and EN render in the same font instead of falling back to a
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
(latin-only) imports — see --font-sans below for the family rename. */
@import '@fontsource-variable/geist';
@import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/500.css';
@import '@fontsource/geist-mono/600.css';
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
declarations so Russian text renders in Geist Mono instead of falling
back to Cascadia/Consolas. */
@import '@fontsource/geist-mono/cyrillic-400.css';
@import '@fontsource/geist-mono/cyrillic-500.css';
@import '@fontsource/geist-mono/cyrillic-600.css';
@import '@fontsource/newsreader/300-italic.css';
@import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.css';
@@ -68,7 +74,7 @@
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif;
+39 -19
View File
@@ -20,7 +20,10 @@
noneLabel = '—',
disabled = false,
size = 'default',
open = $bindable(false),
showTrigger = true,
onselect,
onclose,
}: {
items: EntityItem[];
value: string | number | null;
@@ -29,10 +32,12 @@
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
open?: boolean;
showTrigger?: boolean;
onselect?: (value: string | number | null) => void;
onclose?: () => void;
} = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>();
@@ -52,24 +57,37 @@
return [...result, ...matching];
});
// Focus input whenever the palette transitions to open (covers both internal
// trigger clicks and external programmatic opening via bind:open).
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
wasOpen = open;
});
function openPalette() {
if (disabled) return;
open = true;
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
// Called when the user dismisses the palette (overlay click or ESC).
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
function closePalette() {
open = false;
query = '';
onclose?.();
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
open = false;
query = '';
}
function handleKeydown(e: KeyboardEvent) {
@@ -106,21 +124,23 @@
});
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
{#if showTrigger}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
{/if}
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open}
@@ -0,0 +1,254 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import type { EventLog } from '$lib/types';
import { requestHighlight } from '$lib/highlight';
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
interface Props {
event: EventLog | null;
onclose: () => void;
}
let { event, onclose }: Props = $props();
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
if (!issuer) return '';
if (issuer.username) return '@' + issuer.username;
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
if (name) return name;
if (issuer.id) return 'id ' + issuer.id;
return '';
}
/** Navigate to a list page and highlight the specific entity card.
*
* The destination page calls ``highlightFromUrl()`` after data loads,
* which scrolls to and pulses the card with ``data-entity-id={id}``.
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
function openEntity(path: string, entityId: number | string | null | undefined) {
if (entityId != null) requestHighlight(entityId);
onclose();
goto(path);
}
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuerText = $derived(issuerLabel(issuer));
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!event?.details) return '';
try {
return JSON.stringify(event.details, null, 2);
} catch {
return String(event.details);
}
});
</script>
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
{#if event}
<div class="event-detail">
<!-- Subject + verb -->
<div class="hero-row">
<MdiIcon name="mdiBell" size={18} />
<div>
<div class="hero-subject">{event.collection_name || event.event_type}</div>
<div class="hero-meta">
<span class="event-type">{event.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(event.created_at)}</span>
</div>
</div>
</div>
<!-- Provenance grid -->
<dl class="provenance">
{#if event.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{event.bot_name}</dd>
{/if}
{#if event.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{event.collection_id}</dd>
{/if}
{#if issuerText}
<dt>{t('events.issuer')}</dt>
<dd>
{issuerText}
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
</dd>
{/if}
{#if event.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{event.command_tracker_name}</dd>
{/if}
{#if event.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{event.tracker_name}</dd>
{/if}
{#if event.action_name}
<dt>{t('events.action')}</dt>
<dd>{event.action_name}</dd>
{/if}
{#if event.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{event.provider_name}</dd>
{/if}
{#if event.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{event.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<div class="actions">
{#if event.provider_id}
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if event.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if event.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if event.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && event.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
{/if}
</div>
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
{#if detailsJson && detailsJson !== '{}'}
<details class="raw-details" open={isCommand}>
<summary>{t('events.rawDetails')}</summary>
<pre>{detailsJson}</pre>
</details>
{/if}
</div>
{/if}
</Modal>
<style>
.event-detail {
display: flex; flex-direction: column; gap: 1.1rem;
}
.hero-row {
display: flex; align-items: flex-start; gap: 0.75rem;
}
.hero-subject {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 500;
color: var(--color-foreground);
line-height: 1.3;
word-break: break-word;
}
.hero-meta {
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
display: flex; align-items: center; gap: 0.4rem;
}
.event-type {
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 0.35rem;
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
color: var(--color-foreground);
}
.dot { opacity: 0.5; }
.provenance {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.45rem 1rem;
margin: 0;
padding: 0.85rem 0.95rem;
border-radius: 0.7rem;
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.provenance dt {
color: var(--color-muted-foreground);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
}
.provenance dd {
margin: 0;
color: var(--color-foreground);
word-break: break-word;
}
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
.actions {
display: flex; flex-wrap: wrap; gap: 0.5rem;
}
.actions button {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.8rem;
font-size: 0.78rem;
color: var(--color-foreground);
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
border-radius: 0.55rem;
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.actions button:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
}
.raw-details summary {
font-size: 0.75rem;
color: var(--color-muted-foreground);
cursor: pointer;
user-select: none;
}
.raw-details summary:hover { color: var(--color-foreground); }
.raw-details pre {
margin: 0.55rem 0 0;
padding: 0.7rem 0.85rem;
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.5;
color: var(--color-foreground);
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
border-radius: 0.55rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.font-mono { font-family: var(--font-mono); }
</style>
+16
View File
@@ -108,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
];
// --- Sort filter (dashboard) ---
@@ -117,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Auto-refresh interval (dashboard events list) ---
//
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
// in routes/+page.svelte if you add or remove cadences.
export const refreshIntervalItems = (): GridItem[] => [
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
];
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
+119 -3
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Service notifications"
},
"crumbs": {
"routingNotification": "Routing · Notification",
"routingCommands": "Routing · Commands",
"routingTargets": "Routing · Targets",
"routingAutomation": "Routing · Automation",
"operatorsBots": "Operators · Bots",
"systemAccess": "System · Access",
"systemConfiguration": "System · Configuration",
"systemMaintenance": "System · Maintenance",
"serviceConnections": "Service · Connections"
},
"nav": {
"sectionOverview": "Overview",
"sectionRouting": "Routing",
@@ -87,6 +98,15 @@
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"commandHandled": "command handled",
"commandRateLimited": "rate limited",
"commandFailed": "command failed",
"autoRefreshTitle": "Auto-refresh interval for the events list",
"refreshOff": "Off",
"refresh10s": "10s",
"refresh30s": "30s",
"refresh60s": "1m",
"refresh5m": "5m",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -97,6 +117,9 @@
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"filterCommandHandled": "Command Handled",
"filterCommandRateLimited": "Rate Limited",
"filterCommandFailed": "Command Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
@@ -141,6 +164,23 @@
"newTracker": "New tracker",
"eventsTotal": "Events"
},
"events": {
"detailTitle": "Event details",
"bot": "Bot",
"chat": "Chat",
"issuer": "Issued by",
"commandTracker": "Command tracker",
"tracker": "Tracker",
"action": "Action",
"provider": "Provider",
"assetsCount": "Assets",
"openProvider": "Open provider",
"openBot": "Open bot",
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details"
},
"providers": {
"title": "Service",
"titleEmphasis": "providers",
@@ -313,6 +353,7 @@
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
@@ -420,7 +461,13 @@
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
"receiverDisabled": "Receiver disabled",
"groupNoBot": "No bot linked",
"groupDirect": "Direct delivery",
"groupBotMissing": "Unknown bot",
"target": "target",
"targetsLower": "targets",
"openBot": "Open bot"
},
"users": {
"titleEmphasis": "& access",
@@ -789,7 +836,41 @@
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
"logLevels": "Per-Module Overrides",
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Settings saved"
"saved": "Settings saved",
"identity": "Identity",
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
"telegramHeadline": "Webhook authentication and media cache tuning",
"loggingHeadline": "Verbosity, output format, and per-module overrides",
"heroNoUrl": "External URL not set",
"heroNoLocales": "no locales",
"copy": "Copy",
"urlCopied": "URL copied",
"openExternal": "Open",
"show": "Show",
"hide": "Hide",
"secretSet": "Verified",
"secretUnset": "Not configured",
"cacheConfig": "Cache",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Max entries",
"cacheMaxFootnote": "per bucket (LRU)",
"hoursShort": "hrs",
"entriesShort": "max",
"ttlNoExpiry": "no expiry",
"cacheCapacity": "Cache capacity",
"cacheCapacityCap": "of {n} cap",
"logModulePlaceholder": "module.path",
"addOverride": "Add override",
"removeOverride": "Remove",
"editAsText": "Edit as text",
"editAsChips": "Edit as chips",
"logPreviewLabel": "ACTIVE",
"unsavedChanges": "Unsaved changes",
"unsaved": "UNSAVED",
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes"
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -889,6 +970,7 @@
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Command Configs",
"noCommandsForProvider": "No commands available for this provider type.",
"description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config",
"name": "Name",
@@ -1025,6 +1107,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1149,6 +1233,14 @@
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"commandHandled": "Bot command served",
"commandRateLimited": "Bot command throttled",
"commandFailed": "Bot command crashed",
"refreshOff": "Auto-refresh disabled",
"refresh10s": "Refresh every 10 seconds",
"refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1331,6 +1423,30 @@
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
"restartingDescription": "The page will reload once the server is back online.",
"countLabel": "backups",
"scheduleOn": "Auto · every {h}h",
"scheduleOff": "Auto backup off",
"lastBackup": "Last {ago}",
"never": "no backups yet",
"totalSize": "{size} total",
"dropZone": "Drop a JSON backup here, or click to choose",
"dropZoneActive": "Release to load",
"changeFile": "Change file",
"catGroupIdentity": "Identity & Routing",
"catGroupNotif": "Notifications",
"catGroupCmd": "Commands",
"catGroupSystem": "System",
"stepCategories": "What to include",
"stepSecrets": "Secrets handling",
"stepDownload": "Download",
"stepFile": "Choose a file",
"stepValidate": "Validate contents",
"stepConflict": "On conflict",
"stepApply": "Apply",
"tagScheduled": "scheduled",
"tagManual": "manual",
"tagSecrets": "with secrets",
"validateFirst": "Validate the file first to enable import"
}
}
+119 -3
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Уведомления о сервисах"
},
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": {
"sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация",
@@ -87,6 +98,15 @@
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"commandHandled": "команда обработана",
"commandRateLimited": "ограничение частоты",
"commandFailed": "команда упала",
"autoRefreshTitle": "Интервал авто-обновления списка событий",
"refreshOff": "Выкл",
"refresh10s": "10с",
"refresh30s": "30с",
"refresh60s": "1м",
"refresh5m": "5м",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -97,6 +117,9 @@
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"filterCommandHandled": "Команда обработана",
"filterCommandRateLimited": "Ограничение частоты",
"filterCommandFailed": "Команда упала",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
@@ -141,6 +164,23 @@
"newTracker": "Новый трекер",
"eventsTotal": "Событий"
},
"events": {
"detailTitle": "Детали события",
"bot": "Бот",
"chat": "Чат",
"issuer": "Отправитель",
"commandTracker": "Командный трекер",
"tracker": "Трекер",
"action": "Действие",
"provider": "Провайдер",
"assetsCount": "Файлов",
"openProvider": "Открыть провайдера",
"openBot": "Открыть бота",
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
},
"providers": {
"title": "Сервисные",
"titleEmphasis": "провайдеры",
@@ -313,6 +353,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -420,7 +461,13 @@
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
"receiverDisabled": "Получатель отключён",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
"target": "получатель",
"targetsLower": "получателей",
"openBot": "Открыть бота"
},
"users": {
"titleEmphasis": "и доступ",
@@ -789,7 +836,41 @@
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Настройки сохранены"
"saved": "Настройки сохранены",
"identity": "Идентификация",
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
"heroNoUrl": "Внешний URL не задан",
"heroNoLocales": "нет локалей",
"copy": "Копировать",
"urlCopied": "URL скопирован",
"openExternal": "Открыть",
"show": "Показать",
"hide": "Скрыть",
"secretSet": "Задан",
"secretUnset": "Не настроен",
"cacheConfig": "Кэш",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Макс. записей",
"cacheMaxFootnote": "на корзину (LRU)",
"hoursShort": "ч",
"entriesShort": "макс",
"ttlNoExpiry": "без срока",
"cacheCapacity": "Заполненность кэша",
"cacheCapacityCap": "из {n}",
"logModulePlaceholder": "путь.модуля",
"addOverride": "Добавить",
"removeOverride": "Удалить",
"editAsText": "Редактировать как текст",
"editAsChips": "Редактировать как чипы",
"logPreviewLabel": "АКТИВНО",
"unsavedChanges": "Несохранённые изменения",
"unsaved": "НЕ СОХРАНЕНО",
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить"
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -889,6 +970,7 @@
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -1025,6 +1107,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1149,6 +1233,14 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1331,6 +1423,30 @@
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
"countLabel": "бэкапов",
"scheduleOn": "Авто · каждые {h}ч",
"scheduleOff": "Авто-бэкап выключен",
"lastBackup": "Последний {ago}",
"never": "ещё нет бэкапов",
"totalSize": "всего {size}",
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
"dropZoneActive": "Отпустите для загрузки",
"changeFile": "Сменить файл",
"catGroupIdentity": "Идентичность и маршрутизация",
"catGroupNotif": "Уведомления",
"catGroupCmd": "Команды",
"catGroupSystem": "Система",
"stepCategories": "Что включить",
"stepSecrets": "Обработка секретов",
"stepDownload": "Скачать",
"stepFile": "Выберите файл",
"stepValidate": "Проверить содержимое",
"stepConflict": "При конфликте",
"stepApply": "Применить",
"tagScheduled": "по расписанию",
"tagManual": "вручную",
"tagSecrets": "с секретами",
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
}
}
+7
View File
@@ -217,9 +217,16 @@ export interface EventLog {
event_type: string;
collection_id: string;
collection_name: string;
tracker_id?: number | null;
tracker_name: string;
provider_name: string;
provider_id: number | null;
action_id?: number | null;
action_name?: string;
command_tracker_id?: number | null;
command_tracker_name?: string;
telegram_bot_id?: number | null;
bot_name?: string;
assets_count: number;
details: Record<string, any>;
created_at: string;
+170 -18
View File
@@ -16,12 +16,13 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import type { DashboardStatus } from '$lib/types';
import type { DashboardStatus, EventLog } from '$lib/types';
const SECTIONS_KEY = 'dashboard_section_state';
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
@@ -75,10 +76,57 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
// this whitelist in sync with that helper so a stale localStorage
// value can't smuggle in an unsupported interval (e.g. someone
// hand-edits to 1).
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
function loadRefreshSeconds(): number {
if (typeof localStorage === 'undefined') return 0;
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
const v = stored ? parseInt(stored, 10) : 0;
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
}
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds());
let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade
// (~600ms of fade-up per row) which reads as the panel "reconstructing".
let eventsAnimated = $state(false);
// Auto-refresh ticker — re-creates the interval whenever the user
// changes the cadence. ``$effect`` returns a cleanup that fires on
// destroy AND on any tracked dep change, so the prior timer is torn
// down before a new one starts.
$effect(() => {
if (refreshSeconds <= 0) return;
// Pause auto-refresh when the tab is hidden so we don't burn API
// calls on a tab the user can't see — we'll catch up on the next
// visibility flip via ``visibilitychange`` below.
const tick = () => {
if (typeof document !== 'undefined' && document.hidden) return;
loadEvents({ silent: true });
loadChart();
};
const handle = setInterval(tick, refreshSeconds * 1000);
return () => clearInterval(handle);
});
// Persist whenever the cadence changes (the IconGridSelect mutates
// ``refreshSeconds`` directly via bind:value).
let _refreshHydrated = false;
$effect(() => {
const v = refreshSeconds;
if (!_refreshHydrated) { _refreshHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
});
async function clearEvents() {
try {
@@ -119,22 +167,53 @@
return params;
}
async function loadEvents() {
eventsLoading = true;
/** Reload the events panel.
*
* ``silent`` is set by the auto-refresh ticker so the loading
* placeholder doesn't flash and the row list isn't disturbed when
* nothing actually changed. We diff the new payload against the
* current ``status`` and reuse the existing ``recent_events`` array
* reference when the ID list is identical — that lets Svelte's keyed
* ``{#each}`` skip its diff entirely instead of patching every row.
*/
async function loadEvents(opts: { silent?: boolean } = {}) {
if (!opts.silent) eventsLoading = true;
try {
const params = buildFilterParams();
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
// Nothing changed in the visible page. Update only the
// out-of-band counts so the header and pager stay accurate;
// keep the existing array reference so no row re-renders.
status = {
...status,
providers: next.providers,
trackers: next.trackers,
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
};
return;
}
status = next;
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
eventsLoading = false;
if (!opts.silent) eventsLoading = false;
}
}
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
return true;
}
async function loadChart() {
try {
const params = buildFilterParams();
@@ -204,6 +283,16 @@
}
}
// Disable stagger entry animation once the first non-empty list has
// rendered + had time to play. Subsequent pagination/filter reloads
// then settle in place instead of re-running the cascade.
$effect(() => {
if (eventsAnimated) return;
if (!status?.recent_events?.length) return;
const handle = setTimeout(() => { eventsAnimated = true; }, 700);
return () => clearTimeout(handle);
});
const filteredProviderCount = $derived(globalProviderFilter.providerType
? providers.filter(p => p.type === globalProviderFilter.providerType).length
: displayProviders);
@@ -360,6 +449,9 @@
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
command_handled: 'dashboard.commandHandled',
command_rate_limited: 'dashboard.commandRateLimited',
command_failed: 'dashboard.commandFailed',
};
const eventIcons: Record<string, string> = {
@@ -367,6 +459,7 @@
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
};
// Aurora gradient palette per event type — used for the avatar tile
@@ -380,6 +473,9 @@
action_success: ['var(--color-mint)', 'var(--color-primary)'],
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
};
const STAT_ACCENTS = [
@@ -554,6 +650,11 @@
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
<IconGridSelect items={refreshIntervalItems()}
bind:value={refreshSeconds}
columns={5} compact />
</div>
</div>
{#snippet paginator()}
@@ -588,17 +689,25 @@
</div>
{/snippet}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else if status.recent_events.length === 0}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{#if status.recent_events.length === 0}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{/if}
{:else}
<div class="signal-list stagger-children">
{#each status.recent_events as event, i}
<div class="signal-row" style="animation-delay: {i * 60}ms;">
<div class="signal-list"
class:stagger-children={!eventsAnimated}
class:signal-list--reloading={eventsLoading}
aria-busy={eventsLoading}>
{#each status.recent_events as event, i (event.id)}
<button type="button" class="signal-row signal-row--clickable"
style={eventsAnimated ? '' : `animation-delay: ${i * 60}ms;`}
onclick={() => selectedEvent = event}
aria-label={t('events.detailTitle')}>
<div class="signal-avatar"
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
@@ -615,7 +724,29 @@
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#if event.tracker_name}
{#if event.event_type?.startsWith('command_')}
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
{@const issuerLabel = issuer
? (issuer.username ? '@' + issuer.username : [issuer.first_name, issuer.last_name].filter(Boolean).join(' ') || ('id ' + issuer.id))
: ''}
<div class="signal-trail">
{#if event.bot_name}
<span class="ch"><MdiIcon name="mdiRobotHappy" size={11} />{event.bot_name}</span>
{/if}
{#if event.collection_id}
{#if event.bot_name}<span class="arrow"></span>{/if}
<span class="ch"><MdiIcon name="mdiChatProcessing" size={11} />{event.collection_id}</span>
{/if}
{#if issuerLabel}
<span class="arrow"></span>
<span class="ch"><MdiIcon name="mdiAccount" size={11} />{issuerLabel}</span>
{/if}
{#if event.provider_name}
<span class="arrow"></span>
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
{/if}
</div>
{:else if event.tracker_name}
<div class="signal-trail">
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
{#if event.provider_name}
@@ -629,7 +760,7 @@
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
</button>
{/each}
</div>
@@ -790,6 +921,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style>
/* ============================================================
HERO
@@ -1118,6 +1251,11 @@
SIGNAL STREAM — events with routing trail
============================================================ */
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
/* Soft dim while a page change / filter reload is in flight. We keep
the previous rows mounted (avoids the layout collapsing to a tiny
"Loading…" placeholder) and just nudge opacity so the swap feels
like a refresh rather than a teardown. */
.signal-list--reloading { opacity: 0.55; pointer-events: none; transition: opacity 0.15s ease; }
.signal-row {
display: grid;
grid-template-columns: 40px 1fr auto;
@@ -1129,6 +1267,20 @@
}
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
.signal-row:hover { background: var(--color-glass-strong); }
/* Row is rendered as <button> for clickability — strip default chrome
and align children left like the prior <div> layout. */
.signal-row--clickable {
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: transparent;
border: 0;
}
.signal-row--clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.signal-avatar {
width: 40px; height: 40px;
border-radius: 12px;
+1 -1
View File
@@ -199,7 +199,7 @@
title={t('actions.title')}
emphasis={t('actions.titleEmphasis')}
description={t('actions.description')}
crumb="Routing · Automation"
crumb={t('crumbs.routingAutomation')}
count={actions.length}
countLabel={t('actions.countLabel')}
pills={headerPills}
+1 -1
View File
@@ -99,7 +99,7 @@
title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={emailBots.length}
countLabel={t('emailBot.countLabel')}
>
+1 -1
View File
@@ -97,7 +97,7 @@
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
@@ -303,7 +303,7 @@
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
@@ -205,7 +205,7 @@
title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('commandConfig.countLabel')}
pills={headerPills}
@@ -422,7 +422,7 @@
title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
@@ -278,7 +278,7 @@
title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={trackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -468,7 +468,7 @@
title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -227,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
</div>
</div>
{/if}
+1 -1
View File
@@ -198,7 +198,7 @@
title={t('providers.title')}
emphasis={t('providers.titleEmphasis')}
description={t('providers.description')}
crumb="Service · Connections"
crumb={t('crumbs.serviceConnections')}
count={providers.length}
countLabel={t('dashboard.providersShort')}
pills={headerPills}
+148 -187
View File
@@ -2,21 +2,19 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
import SettingsHero from './SettingsHero.svelte';
import IdentityCassette from './IdentityCassette.svelte';
import TelegramCassette from './TelegramCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import SaveBar from './SaveBar.svelte';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
@@ -28,12 +26,19 @@
asset: CacheBucketStats;
}
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state({
interface Settings {
external_url: string;
telegram_webhook_secret: string;
telegram_cache_ttl_hours: string;
telegram_asset_cache_max_entries: string;
supported_locales: string;
timezone: string;
log_level: string;
log_format: string;
log_levels: string;
}
const EMPTY: Settings = {
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '720',
@@ -43,10 +48,33 @@
log_level: 'INFO',
log_format: 'text',
log_levels: '',
});
};
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state<Settings>({ ...EMPTY });
// Snapshot of the last server-known state, used for dirty tracking.
let baseline = $state<Settings>({ ...EMPTY });
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
// --- Dirty tracking -----------------------------------------------------
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
const out: Array<keyof Settings> = [];
for (const key of Object.keys(settings) as Array<keyof Settings>) {
if (settings[key] !== baseline[key]) out.push(key);
}
return out;
});
const dirty = $derived(dirtyKeys.length > 0);
// --- Data loading -------------------------------------------------------
async function loadCacheStats(): Promise<void> {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
@@ -54,202 +82,135 @@
onMount(async () => {
try {
settings = await api('/settings');
const fetched = await api<Settings>('/settings');
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; }
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
snackError(msg);
} finally {
loaded = true;
}
});
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
// --- Actions ------------------------------------------------------------
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
async function save(): Promise<void> {
saving = true;
error = '';
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
const next = await api<Settings>('/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
settings = { ...EMPTY, ...next };
baseline = { ...settings };
externalUrlCache.invalidate();
snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
error = msg;
snackError(msg);
} finally {
saving = false;
}
}
async function clearTelegramCache() {
function discard(): void {
settings = { ...baseline };
}
async function clearTelegramCache(): Promise<void> {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Clear cache failed';
snackError(msg);
} finally {
clearingCache = false;
}
}
const cacheMaxEntriesNum = $derived(
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
);
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
<SettingsHero {settings} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
<div class="space-y-6">
<!-- General section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiCog" size={18} />
{t('settings.general')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div>
</Card>
<!-- Telegram section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiSend" size={18} />
{t('settings.telegram')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div>
</Card>
<div class="settings-page stagger-children">
<IdentityCassette
bind:externalUrl={settings.external_url}
bind:timezone={settings.timezone}
bind:supportedLocales={settings.supported_locales}
/>
<!-- Locales section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTranslate" size={18} />
{t('settings.locales')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<LocaleSelector bind:value={settings.supported_locales} />
</div>
</div>
</Card>
<div class="telegram-deck">
<TelegramCassette
bind:webhookSecret={settings.telegram_webhook_secret}
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
/>
<CacheLedger
stats={cacheStats}
clearing={clearingCache}
maxEntries={cacheMaxEntriesNum}
onRefresh={loadCacheStats}
onClear={() => (confirmClearCache = true)}
/>
</div>
<!-- Logging section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTextBoxOutline" size={18} />
{t('settings.logging')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
<input bind:value={settings.log_levels}
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
</div>
</Card>
<Button onclick={save} disabled={saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
bind:logLevels={settings.log_levels}
/>
</div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
<SaveBar
{dirty}
{saving}
changedCount={dirtyKeys.length}
onSave={save}
onDiscard={discard}
/>
{/if}
<ConfirmModal
open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => (confirmClearCache = false)}
/>
<style>
.settings-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.telegram-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.telegram-deck { grid-template-columns: 1fr 1fr; }
}
</style>
@@ -0,0 +1,404 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
oldest: string | null;
newest: string | null;
}
interface CacheStats {
url: CacheBucketStats;
asset: CacheBucketStats;
}
interface Props {
stats: CacheStats | null;
clearing: boolean;
maxEntries: number;
onRefresh: () => void;
onClear: () => void;
}
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function parseDate(iso: string | null): Date | null {
if (!iso) return null;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? null : d;
}
function relativeTime(iso: string | null): string {
const date = parseDate(iso);
if (!date) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function ageTone(iso: string | null): Tone {
const date = parseDate(iso);
if (!date) return 'mint';
const hours = (Date.now() - date.getTime()) / 3_600_000;
if (hours < 48) return 'mint';
if (hours < 24 * 7) return 'sky';
if (hours < 24 * 30) return 'citrus';
return 'coral';
}
interface BucketRow {
key: 'url' | 'asset';
labelKey: string;
icon: string;
data: CacheBucketStats | null;
}
const buckets = $derived<BucketRow[]>([
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
]);
const totalCount = $derived(
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
);
const totalBytes = $derived(
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
);
const fillPct = $derived.by(() => {
const max = Math.max(1, maxEntries);
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
void each; // explicit ack we considered both
return Math.min(100, Math.round((top / max) * 100));
});
</script>
<section class="ledger glass">
<header class="ledger-head">
<div class="ledger-summary">
<div class="ledger-eyebrow">
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
<span>{t('settings.cacheStats')}</span>
</div>
<div class="ledger-numbers">
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
{#if totalBytes > 0}
<span class="ledger-sep">·</span>
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
{/if}
</div>
</div>
<div class="ledger-actions">
<button
type="button"
class="icon-btn"
onclick={onRefresh}
aria-label={t('common.refresh', 'Refresh')}
title={t('common.refresh', 'Refresh')}
>
<MdiIcon name="mdiRefresh" size={16} />
</button>
</div>
</header>
<!-- Capacity meter (peak bucket vs configured cap) -->
{#if maxEntries > 0}
<div class="meter" aria-label={t('settings.cacheCapacity')}>
<div class="meter-track">
<div class="meter-fill" style="width: {fillPct}%"></div>
</div>
<span class="meter-text font-mono">
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
</span>
</div>
{/if}
<!-- Bucket rows -->
<ol class="ledger-list">
{#each buckets as bucket (bucket.key)}
{@const data = bucket.data}
{@const empty = !data || data.count === 0}
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
<li class="row" data-tone={tone} class:row-empty={empty}>
<span class="row-edge" aria-hidden="true"></span>
<span class="row-icon" aria-hidden="true">
<MdiIcon name={bucket.icon} size={16} />
</span>
<div class="row-text">
<span class="row-name">{t(bucket.labelKey)}</span>
{#if empty}
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
{:else if data}
<span class="row-meta">
<span>
<span class="font-mono">{data.count.toLocaleString()}</span>
{t('settings.cacheStatsEntries')}
</span>
{#if data.total_size_bytes > 0}
<span class="row-sep">·</span>
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
{/if}
{#if data.oldest}
<span class="row-sep">·</span>
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
{/if}
</span>
{/if}
</div>
<span class="row-dot" aria-hidden="true"></span>
</li>
{/each}
</ol>
<footer class="ledger-foot">
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
{#if clearing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDeleteSweep" size={14} />
{/if}
{clearing ? t('common.loading') : t('settings.clearCache')}
</Button>
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
</footer>
</section>
<style>
.ledger {
padding: 1.4rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 100%;
}
.ledger-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ledger-summary { min-width: 0; }
.ledger-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.4rem;
}
.ledger-numbers {
display: flex;
align-items: baseline;
gap: 0.45rem;
line-height: 1;
flex-wrap: wrap;
}
.ledger-count {
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.ledger-count-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
.ledger-bytes {
font-size: 0.78rem;
color: var(--color-muted-foreground);
}
.ledger-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 30px; height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
/* --- Capacity meter --- */
.meter {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.65rem;
}
.meter-track {
flex: 1;
height: 6px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
overflow: hidden;
position: relative;
}
.meter-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
border-radius: inherit;
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
}
.meter-text {
font-size: 0.62rem;
color: var(--color-muted-foreground);
letter-spacing: 0.04em;
white-space: nowrap;
}
/* --- Bucket rows --- */
.ledger-list {
position: relative;
z-index: 1;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.85rem;
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
transition: transform 0.18s, border-color 0.18s, background 0.18s;
overflow: hidden;
}
.row:hover {
transform: translateY(-1px);
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.row.row-empty { opacity: 0.78; }
.row-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
.row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px; height: 30px;
border-radius: 9px;
background: var(--color-glass);
color: var(--color-foreground);
}
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
.row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.row-name {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
}
.row-meta {
font-size: 0.7rem;
color: var(--color-muted-foreground);
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.row-sep { opacity: 0.45; }
.row-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
/* --- Footer --- */
.ledger-foot {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
padding-top: 0.4rem;
margin-top: auto;
}
.foot-hint {
font-size: 0.7rem;
color: var(--color-muted-foreground);
flex: 1;
min-width: 12rem;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
.row, .meter-fill { transition: none !important; }
.row:hover { transform: none !important; }
}
</style>
@@ -0,0 +1,277 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess } from '$lib/stores/snackbar.svelte';
interface Props {
externalUrl: string;
timezone: string;
supportedLocales: string;
}
let {
externalUrl = $bindable(),
timezone = $bindable(),
supportedLocales = $bindable(),
}: Props = $props();
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | null = null;
function copyUrl(): void {
if (!externalUrl) return;
try {
navigator.clipboard.writeText(externalUrl);
copied = true;
snackSuccess(t('settings.urlCopied'));
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copied = false; }, 1600);
} catch { /* ignore */ }
}
function isReachable(url: string): boolean {
if (!url) return false;
try { new URL(url); return true; } catch { return false; }
}
const urlValid = $derived(isReachable(externalUrl));
</script>
<section class="identity glass">
<header class="identity-head">
<div class="identity-eyebrow">
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
<span>{t('settings.identity')}</span>
</div>
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
</header>
<div class="identity-body">
<!-- External URL row -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<label for="settings-external-url" class="row-name">
{t('settings.externalUrl')}
<Hint text={t('settings.externalUrlHint')} />
</label>
</div>
<div class="row-control">
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
<span class="url-leading" aria-hidden="true">
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
</span>
<input
id="settings-external-url"
bind:value={externalUrl}
placeholder="https://notify.example.com"
class="url-input"
type="url"
autocomplete="off"
spellcheck="false"
/>
{#if externalUrl}
<button
type="button"
class="url-action"
onclick={copyUrl}
aria-label={t('settings.copy')}
title={t('settings.copy')}
>
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
</button>
{#if urlValid}
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
class="url-action"
aria-label={t('settings.openExternal')}
title={t('settings.openExternal')}
>
<MdiIcon name="mdiOpenInNew" size={13} />
</a>
{/if}
{/if}
</div>
</div>
</div>
<!-- Timezone row -->
<div class="row">
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.timezone')}
<Hint text={t('settings.timezoneHint')} />
</span>
</div>
<div class="row-control">
<TimezoneSelector bind:value={timezone} />
</div>
</div>
<!-- Locales row -->
<div class="row">
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.supportedLocales')}
<Hint text={t('settings.supportedLocalesHint')} />
</span>
</div>
<div class="row-control">
<LocaleSelector bind:value={supportedLocales} />
</div>
</div>
</div>
</section>
<style>
.identity {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.identity-head {
position: relative;
z-index: 1;
}
.identity-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;
}
.identity-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 42ch;
}
.identity-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 1.4rem;
padding: 1rem 0;
border-top: 1px solid var(--color-border);
}
.row:first-child { border-top: 0; padding-top: 0.4rem; }
.row:last-child { padding-bottom: 0.1rem; }
.row-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.15rem;
}
.row-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.row-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
display: inline-flex;
align-items: center;
}
.row-control {
min-width: 0;
}
/* --- URL field with leading icon and trailing actions --- */
.url-field {
display: flex;
align-items: center;
gap: 0.25rem;
max-width: 34rem;
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
background: var(--color-input-bg);
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.url-field:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.url-field-valid {
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
}
.url-leading {
color: var(--color-muted-foreground);
display: inline-flex;
flex-shrink: 0;
}
.url-field-valid .url-leading { color: var(--color-mint); }
.url-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
padding: 0.5rem 0.4rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-foreground);
min-width: 0;
}
.url-input::placeholder { color: var(--color-muted-foreground); }
.url-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.url-action:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.url-field, .url-action { transition: none !important; }
}
</style>
@@ -0,0 +1,448 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
interface Override {
module: string;
level: Level;
}
interface Props {
logLevel: string;
logFormat: string;
logLevels: string;
}
let {
logLevel = $bindable(),
logFormat = $bindable(),
logLevels = $bindable(),
}: Props = $props();
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
const LEVEL_TONE: Record<Level, string> = {
DEBUG: 'sky',
INFO: 'mint',
WARNING: 'citrus',
ERROR: 'coral',
};
let rawMode = $state(false);
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
function parse(csv: string): Override[] {
if (!csv) return [];
const out: Override[] = [];
const seen = new Set<string>();
for (const raw of csv.split(',')) {
const piece = raw.trim();
if (!piece) continue;
const eq = piece.indexOf('=');
if (eq < 0) continue;
const module = piece.slice(0, eq).trim();
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
if (!module || seen.has(module)) continue;
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
seen.add(module);
out.push({ module, level });
}
return out;
}
function serialize(rows: Override[]): string {
return rows
.filter(r => r.module.trim().length > 0)
.map(r => `${r.module.trim()}=${r.level}`)
.join(',');
}
let rows = $state<Override[]>(parse(logLevels));
let lastEmitted = $state(logLevels);
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
$effect(() => {
if (logLevels !== lastEmitted) {
rows = parse(logLevels);
lastEmitted = logLevels;
}
});
function commit(next: Override[]): void {
rows = next;
const serialized = serialize(next);
lastEmitted = serialized;
logLevels = serialized;
}
function addRow(): void {
commit([...rows, { module: '', level: 'INFO' }]);
}
function removeRow(i: number): void {
commit(rows.filter((_, idx) => idx !== i));
}
function updateModule(i: number, value: string): void {
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
commit(next);
}
function updateLevel(i: number, level: Level): void {
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
commit(next);
}
const previewLine = $derived.by(() => {
const root = (logLevel || 'INFO').toUpperCase();
if (rows.length === 0) return `root=${root}`;
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
});
</script>
<section class="logging glass">
<header class="log-head">
<div class="log-eyebrow">
<MdiIcon name="mdiTextBoxOutline" size={12} />
<span>{t('settings.logging')}</span>
</div>
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
</header>
<!-- Level + format -->
<div class="log-row">
<div class="log-cell">
<span class="log-label">
{t('settings.logLevel')}
<Hint text={t('settings.logLevelHint')} />
</span>
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
</div>
<div class="log-cell">
<span class="log-label">
{t('settings.logFormat')}
<Hint text={t('settings.logFormatHint')} />
</span>
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
</div>
</div>
<!-- Per-module overrides -->
<div class="overrides">
<div class="overrides-head">
<span class="log-label">
{t('settings.logLevels')}
<Hint text={t('settings.logLevelsHint')} />
</span>
<button
type="button"
class="mode-toggle"
onclick={() => (rawMode = !rawMode)}
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
>
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
</button>
</div>
{#if rawMode}
<input
bind:value={logLevels}
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
class="raw-input"
/>
{:else}
<div class="chip-stack">
{#each rows as row, i (i)}
{@const tone = LEVEL_TONE[row.level]}
<div class="chip" data-tone={tone}>
<span class="chip-edge" aria-hidden="true"></span>
<input
value={row.module}
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
placeholder={t('settings.logModulePlaceholder')}
class="chip-input"
autocomplete="off"
spellcheck="false"
/>
<span class="chip-sep" aria-hidden="true">=</span>
<select
value={row.level}
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
class="chip-level"
aria-label={t('settings.logLevel')}
>
{#each LEVELS as lvl}
<option value={lvl}>{lvl}</option>
{/each}
</select>
<button
type="button"
class="chip-remove"
onclick={() => removeRow(i)}
aria-label={t('settings.removeOverride')}
title={t('settings.removeOverride')}
>
<MdiIcon name="mdiClose" size={13} />
</button>
</div>
{/each}
<button type="button" class="chip-add" onclick={addRow}>
<MdiIcon name="mdiPlus" size={13} />
<span>{t('settings.addOverride')}</span>
</button>
</div>
{/if}
<!-- Live preview -->
<div class="preview" role="status">
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
<code class="preview-text">{previewLine}</code>
</div>
</div>
</section>
<style>
.logging {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.log-head {
position: relative;
z-index: 1;
}
.log-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;
}
.log-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;
}
.log-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
@media (min-width: 720px) {
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
}
.log-cell {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.log-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-foreground);
display: inline-flex;
align-items: center;
}
/* --- Overrides editor --- */
.overrides {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.overrides-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.mode-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.12em;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.mode-toggle:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
.raw-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.6rem 0.85rem;
}
.chip-stack {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chip {
position: relative;
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
overflow: hidden;
transition: border-color 0.18s, background 0.18s;
}
.chip:hover {
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.chip-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
.chip-input {
width: 100%;
background: transparent;
border: 0;
outline: 0;
padding: 0.35rem 0;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--color-foreground);
min-width: 0;
}
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
.chip-sep {
font-family: var(--font-mono);
color: var(--color-muted-foreground);
opacity: 0.5;
padding: 0 0.15rem;
}
.chip-level {
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 500;
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-glass);
color: var(--color-foreground);
min-width: 7.2rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
.chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px; height: 26px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chip-remove:hover {
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
color: var(--color-error-fg);
}
.chip-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
align-self: flex-start;
padding: 0.35rem 0.85rem;
border-radius: 999px;
border: 1px dashed var(--color-rule-strong);
background: transparent;
color: var(--color-muted-foreground);
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip-add:hover {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-primary);
border-style: solid;
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
}
/* --- Live preview --- */
.preview {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.65rem 0.85rem;
border-radius: 12px;
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
border: 1px solid var(--color-border);
overflow: hidden;
}
.preview-eyebrow {
font-family: var(--font-mono);
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.preview-text {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-foreground);
word-break: break-all;
line-height: 1.45;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface Props {
dirty: boolean;
saving: boolean;
changedCount: number;
onSave: () => void;
onDiscard: () => void;
}
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
</script>
{#if dirty || saving}
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
<div class="save-bar-inner glass">
<span class="save-edge" aria-hidden="true"></span>
<span class="save-pulse" aria-hidden="true"></span>
<div class="save-text">
<span class="save-eyebrow">{t('settings.unsaved')}</span>
<span class="save-message">
{#if changedCount === 1}
{t('settings.changedOne')}
{:else}
{t('settings.changedMany').replace('{n}', String(changedCount))}
{/if}
</span>
</div>
<div class="save-actions">
<button
type="button"
class="discard"
onclick={onDiscard}
disabled={saving}
>
{t('settings.discard')}
</button>
<Button size="sm" onclick={onSave} disabled={saving}>
{#if saving}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiContentSave" size={14} />
{/if}
{saving ? t('common.loading') : t('settings.saveChanges')}
</Button>
</div>
</div>
</div>
{/if}
<style>
.save-bar {
position: sticky;
bottom: 1rem;
z-index: 40;
margin-top: 1.25rem;
display: flex;
justify-content: center;
pointer-events: none;
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
}
.save-bar-inner {
pointer-events: auto;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.7rem 1rem 0.7rem 1.25rem;
max-width: min(640px, calc(100% - 1rem));
width: 100%;
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
box-shadow:
var(--shadow-card),
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
overflow: hidden;
flex-wrap: wrap;
}
.save-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
}
.save-pulse {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-citrus);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
animation: save-pulse 1.6s ease-in-out infinite;
}
.save-text {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
padding-left: 1rem; /* clear room for the pulse dot */
}
.save-eyebrow {
font-family: var(--font-mono);
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-citrus);
}
.save-message {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 0.95rem;
color: var(--color-foreground);
letter-spacing: -0.01em;
line-height: 1.2;
}
.save-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.discard {
padding: 0 0.95rem;
height: 34px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-family: inherit;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.discard:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
.discard:disabled { opacity: 0.5; cursor: default; }
@keyframes save-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes save-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.save-bar, .save-pulse { animation: none !important; }
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
interface Settings {
external_url: string;
timezone: string;
supported_locales: string;
log_level: string;
log_format: string;
}
interface Props {
settings: Settings;
}
let { settings }: Props = $props();
// Live tick so the timezone pill shows the current local HH:MM.
let now = $state(new Date());
let tick: ReturnType<typeof setInterval> | null = null;
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
onDestroy(() => { if (tick) clearInterval(tick); });
function fmtClock(tz: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz || 'UTC',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now);
} catch { return '--:--'; }
}
function hostFromUrl(url: string): string {
if (!url) return '';
try { return new URL(url).host; }
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
}
function localeCount(csv: string): number {
if (!csv) return 0;
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
}
const SEVERITY_TONE: Record<string, Tone> = {
DEBUG: 'sky',
INFO: 'mint',
WARNING: 'citrus',
ERROR: 'coral',
};
const pills = $derived.by<HeaderPill[]>(() => {
const out: HeaderPill[] = [];
const host = hostFromUrl(settings.external_url);
out.push(host
? { label: host, tone: 'sky' }
: { label: t('settings.heroNoUrl') }
);
const tz = settings.timezone || 'UTC';
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
const locales = settings.supported_locales || '';
const count = localeCount(locales);
out.push({
label: count > 0
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
: t('settings.heroNoLocales'),
tone: 'orchid',
});
const lvl = (settings.log_level || 'INFO').toUpperCase();
out.push({
label: `${lvl} · ${settings.log_format || 'text'}`,
tone: SEVERITY_TONE[lvl] ?? 'mint',
});
return out;
});
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb={t('crumbs.systemConfiguration')}
{pills}
/>
@@ -0,0 +1,344 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
interface Props {
webhookSecret: string;
cacheTtlHours: string;
cacheMaxEntries: string;
}
let {
webhookSecret = $bindable(),
cacheTtlHours = $bindable(),
cacheMaxEntries = $bindable(),
}: Props = $props();
let showSecret = $state(false);
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
const ttlHours = $derived(Number(cacheTtlHours || '0'));
const ttlIsOff = $derived(ttlHours <= 0);
function ttlHumanized(h: number): string {
if (h <= 0) return t('settings.ttlNoExpiry');
if (h < 24) return `${h}h`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d`;
const w = Math.round(d / 7);
if (w < 8) return `${w}w`;
const mo = Math.round(d / 30);
return `${mo}mo`;
}
</script>
<section class="tg glass">
<header class="tg-head">
<div class="tg-eyebrow">
<MdiIcon name="mdiSend" size={12} />
<span>{t('settings.telegram')}</span>
</div>
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
</header>
<div class="tg-grid">
<!-- Webhook secret column -->
<div class="col">
<div class="col-head">
<span class="col-num">A</span>
<span class="col-name">
{t('settings.webhookSecret')}
<Hint text={t('settings.webhookSecretHint')} />
</span>
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
<span class="dot"></span>
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
</span>
</div>
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input
bind:value={webhookSecret}
type={showSecret ? 'text' : 'password'}
autocomplete="off"
placeholder={t('providers.optional')}
class="secret-input"
/>
<button
type="button"
class="secret-toggle"
onclick={() => (showSecret = !showSecret)}
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
title={showSecret ? t('settings.hide') : t('settings.show')}
>
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
</button>
</form>
</div>
<!-- Cache config column -->
<div class="col">
<div class="col-head">
<span class="col-num">B</span>
<span class="col-name">{t('settings.cacheConfig')}</span>
</div>
<div class="cache-grid">
<label class="num-field">
<span class="num-label">
{t('settings.cacheTtlShort')}
<Hint text={t('settings.cacheTtlHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheTtlHours}
type="number"
min="0"
max="8760"
class="num-input"
/>
<span class="num-suffix">{t('settings.hoursShort')}</span>
</div>
<span class="num-meta" class:num-meta-off={ttlIsOff}>
{ttlHumanized(ttlHours)}
</span>
</label>
<label class="num-field">
<span class="num-label">
{t('settings.cacheMaxShort')}
<Hint text={t('settings.cacheMaxEntriesHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheMaxEntries}
type="number"
min="100"
max="100000"
class="num-input"
/>
<span class="num-suffix">{t('settings.entriesShort')}</span>
</div>
<span class="num-meta">
{t('settings.cacheMaxFootnote')}
</span>
</label>
</div>
</div>
</div>
</section>
<style>
.tg {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
min-height: 100%;
}
.tg-head {
position: relative;
z-index: 1;
}
.tg-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;
}
.tg-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: 36ch;
}
.tg-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 720px) {
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
}
.col {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.col-head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.col-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
text-transform: uppercase;
}
.col-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
display: inline-flex;
align-items: center;
}
.col-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
}
.col-status .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-muted-foreground);
}
.col-status[data-state="set"] {
color: var(--color-mint);
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
}
.col-status[data-state="set"] .dot {
background: var(--color-mint);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
}
/* --- Secret field --- */
.secret-field {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
background: var(--color-input-bg);
transition: border-color 0.18s, box-shadow 0.18s;
}
.secret-field:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.secret-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
padding: 0.5rem 0.4rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-foreground);
min-width: 0;
letter-spacing: 0.05em;
}
.secret-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.secret-toggle:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
/* --- Cache config grid --- */
.cache-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
.num-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.7rem 0.85rem 0.65rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.num-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
.num-row {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.num-input {
width: 100%;
padding: 0.1rem 0;
border: 0;
background: transparent;
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--color-foreground);
letter-spacing: -0.015em;
line-height: 1.1;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.num-suffix {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.num-meta {
font-size: 0.7rem;
color: var(--color-mint);
font-family: var(--font-mono);
}
.num-meta-off {
color: var(--color-citrus);
}
@media (max-width: 480px) {
.cache-grid { grid-template-columns: 1fr; }
}
</style>
+261 -400
View File
@@ -2,8 +2,6 @@
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -11,32 +9,55 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
// --- Export state ---
let exportSecrets = $state('exclude');
let exporting = $state(false);
import BackupHero from './BackupHero.svelte';
import PendingStrip from './PendingStrip.svelte';
import ExportPanel from './ExportPanel.svelte';
import ImportPanel from './ImportPanel.svelte';
import ScheduleCassette from './ScheduleCassette.svelte';
import BackupLedger from './BackupLedger.svelte';
const categories = [
{ key: 'providers', label: 'backup.catProviders' },
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
{ key: 'email_bots', label: 'backup.catEmailBots' },
{ key: 'targets', label: 'backup.catTargets' },
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
{ key: 'actions', label: 'backup.catActions' },
{ key: 'app_settings', label: 'backup.catAppSettings' },
type SecretsMode = 'exclude' | 'masked' | 'include';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
const allCategories = [
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
'tracking_configs', 'template_configs',
'command_configs', 'command_template_configs',
'notification_trackers', 'command_trackers',
'actions', 'app_settings',
];
// --- Export state ---
let exportSecrets = $state<SecretsMode>('exclude');
let exporting = $state(false);
let selectedCategories = $state<Record<string, boolean>>(
Object.fromEntries(categories.map(c => [c.key, true]))
Object.fromEntries(allCategories.map(k => [k, true]))
);
// --- Import state ---
let importFile: File | null = $state(null);
let importConflict = $state('skip');
let importConflict = $state<ConflictMode>('skip');
let importing = $state(false);
let validating = $state(false);
let validationResult: any = $state(null);
@@ -47,7 +68,7 @@
// --- Scheduled backup state ---
let loaded = $state(false);
let error = $state('');
let scheduledSettings = $state({
let scheduledSettings = $state<ScheduledSettings>({
backup_scheduled_enabled: 'false',
backup_scheduled_interval_hours: '24',
backup_secrets_mode: 'exclude',
@@ -56,22 +77,22 @@
let savingSchedule = $state(false);
// --- Backup files ---
let backupFiles = $state<any[]>([]);
let backupFiles = $state<BackupFile[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let pending = $state<PendingState | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
api<ScheduledSettings>('/backup/scheduled'),
api<BackupFile[]>('/backup/files'),
api<PendingState>('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
@@ -84,7 +105,7 @@
}
});
async function cancelPending() {
async function cancelPending(): Promise<void> {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
@@ -92,14 +113,13 @@
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
async function applyAndRestart(): Promise<void> {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
@@ -117,7 +137,7 @@
}
}
async function createManualBackup() {
async function createManualBackup(): Promise<void> {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
@@ -132,7 +152,7 @@
}
// --- Export ---
async function doExport() {
async function doExport(): Promise<void> {
if (exportSecrets === 'include') {
confirmExportOpen = true;
return;
@@ -140,7 +160,7 @@
await performExport();
}
async function performExport() {
async function performExport(): Promise<void> {
confirmExportOpen = false;
exporting = true;
try {
@@ -165,8 +185,14 @@
}
}
// --- Validate ---
async function validateFile() {
// --- Validate / Import ---
function handleFileSelect(file: File | null): void {
importFile = file;
validationResult = null;
importResult = null;
}
async function validateFile(): Promise<void> {
if (!importFile) return;
validating = true;
validationResult = null;
@@ -183,12 +209,11 @@
}
}
// --- Import ---
async function doImport() {
function doImport(): void {
confirmImportOpen = true;
}
async function performImport() {
async function performImport(): Promise<void> {
confirmImportOpen = false;
if (!importFile) return;
importing = true;
@@ -213,10 +238,10 @@
}
// --- Scheduled settings ---
async function saveSchedule() {
async function saveSchedule(): Promise<void> {
savingSchedule = true;
try {
scheduledSettings = await api('/backup/scheduled', {
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
method: 'PUT',
body: JSON.stringify(scheduledSettings),
});
@@ -229,10 +254,10 @@
}
// --- File management ---
async function refreshFiles() {
async function refreshFiles(): Promise<void> {
loadingFiles = true;
try {
backupFiles = await api('/backup/files');
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} finally {
@@ -240,7 +265,7 @@
}
}
async function downloadFile(filename: string) {
async function downloadFile(filename: string): Promise<void> {
try {
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -255,7 +280,7 @@
}
}
async function deleteFile(filename: string) {
async function deleteFile(filename: string): Promise<void> {
try {
await api(`/backup/files/${filename}`, { method: 'DELETE' });
snackSuccess(t('backup.fileDeleted'));
@@ -265,355 +290,61 @@
snackError(err.message);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.length) {
importFile = input.files[0];
validationResult = null;
importResult = null;
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
function toggleAll() {
const newVal = !allSelected;
for (const key of Object.keys(selectedCategories)) {
selectedCategories[key] = newVal;
}
}
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
<div class="backup-page stagger-children">
<div class="action-deck">
<ExportPanel
{selectedCategories}
{exportSecrets}
{exporting}
onCategoriesChange={(next) => selectedCategories = next}
onSecretsChange={(next) => exportSecrets = next}
onExport={doExport}
/>
<ImportPanel
{importFile}
{importConflict}
{validating}
{validationResult}
{importing}
{importResult}
onFileSelect={handleFileSelect}
onConflictChange={(mode) => importConflict = mode}
onValidate={validateFile}
onImport={doImport}
/>
</div>
{/if}
<div class="space-y-6">
<ScheduleCassette
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
bind:secretsMode={scheduledSettings.backup_secrets_mode}
bind:retentionCount={scheduledSettings.backup_retention_count}
saving={savingSchedule}
onToggle={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
onSave={saveSchedule}
/>
<!-- Export Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseExport" size={18} />
{t('backup.export')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
<!-- Categories -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium">{t('backup.categories')}</span>
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{#each categories as cat}
<label class="flex items-center gap-1.5 text-xs">
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
{t(cat.label)}
</label>
{/each}
</div>
</div>
<!-- Secrets mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="exclude" />
{t('backup.secretsExclude')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="masked" />
{t('backup.secretsMasked')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="include" />
{t('backup.secretsInclude')}
</label>
</div>
{#if exportSecrets === 'include'}
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiAlert" size={14} />
{t('backup.secretsWarningExport')}
</div>
{/if}
</div>
<Button onclick={doExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</Card>
<!-- Import Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseImport" size={18} />
{t('backup.import')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
<!-- File picker -->
<div class="mb-4">
<input type="file" accept=".json" onchange={handleFileSelect}
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
</div>
{#if importFile}
<!-- Validate -->
<div class="mb-4 flex items-center gap-2">
<Button variant="secondary" onclick={validateFile} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckCircleOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
</div>
{#if validationResult}
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="flex items-center gap-2 mb-2 font-medium">
{#if validationResult.valid}
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
{:else}
<MdiIcon name="mdiCloseCircle" size={14} />
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
{/if}
</div>
{#if Object.keys(validationResult.entity_counts || {}).length}
<div class="mb-2">
<span class="font-medium">{t('backup.entities')}:</span>
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
<span class="inline-block mr-2">{cat}: {count}</span>
{/each}
</div>
{/if}
{#each validationResult.warnings || [] as w}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
<MdiIcon name="mdiAlert" size={12} />
<span>{w}</span>
</div>
{/each}
{#each validationResult.errors || [] as e}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={12} />
<span>{e}</span>
</div>
{/each}
</div>
{/if}
<!-- Conflict mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="skip" />
{t('backup.conflictSkip')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="rename" />
{t('backup.conflictRename')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="overwrite" />
{t('backup.conflictOverwrite')}
</label>
</div>
</div>
<Button onclick={doImport}
disabled={importing || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="font-medium mb-1">{t('backup.importResults')}</div>
<div class="space-y-0.5">
<div>{t('backup.resultCreated')}: {importResult.created}</div>
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
{#if importResult.errors?.length}
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
{#each importResult.errors as e}
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
{/each}
{/if}
{#if importResult.warnings?.length}
{#each importResult.warnings as w}
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
{/each}
{/if}
</div>
</div>
{/if}
{/if}
</Card>
<!-- Scheduled Backups Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiClockOutline" size={18} />
{t('backup.scheduled')}
</h3>
<div class="space-y-3">
<label class="flex items-center gap-2 text-xs">
<input type="checkbox"
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
onchange={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
<span class="font-medium">{t('backup.enableScheduled')}</span>
</label>
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="6">6 {t('backup.hours')}</option>
<option value="12">12 {t('backup.hours')}</option>
<option value="24">24 {t('backup.hours')}</option>
<option value="48">48 {t('backup.hours')}</option>
<option value="72">72 {t('backup.hours')}</option>
<option value="168">168 {t('backup.hours')} (7d)</option>
</select>
</div>
<div>
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="exclude">{t('backup.secretsExclude')}</option>
<option value="masked">{t('backup.secretsMasked')}</option>
<option value="include">{t('backup.secretsInclude')}</option>
</select>
</div>
<div>
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="3">3</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
{/if}
</div>
<div class="mt-4">
<Button onclick={saveSchedule} disabled={savingSchedule}>
{savingSchedule ? t('common.loading') : t('common.save')}
</Button>
</div>
</Card>
<!-- Saved Backup Files -->
<Card>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold flex items-center gap-2">
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
</div>
</div>
{#if backupFiles.length === 0}
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
{:else}
<div class="space-y-2">
{#each backupFiles as file}
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
style="border-color: var(--color-border);">
<div class="flex items-center gap-2">
<MdiIcon name="mdiFileDocument" size={14} />
<span class="font-mono">{file.filename}</span>
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
</div>
<div class="flex items-center gap-1">
<button onclick={() => downloadFile(file.filename)}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button onclick={() => confirmDeleteFile = file.filename}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
style="color: var(--color-error-fg);">
<MdiIcon name="mdiDelete" size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</Card>
<BackupLedger
files={backupFiles}
loading={loadingFiles}
creating={creatingBackup}
onCreate={createManualBackup}
onRefresh={refreshFiles}
onDownload={downloadFile}
onDelete={(filename) => confirmDeleteFile = filename}
/>
</div>
{/if}
@@ -652,27 +383,25 @@
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
class="post-restore-card"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<div class="post-restore-head">
<div class="post-restore-icon">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
<div class="post-restore-text">
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
<p>{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
<div class="post-restore-actions">
<button class="post-restore-later" type="button"
onclick={() => postRestoreModalOpen = false}>
{t('backup.applyLater')}
</button>
{#if pending.supervised}
@@ -687,30 +416,162 @@
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<div class="restart-overlay" role="alert" aria-live="assertive">
<div class="restart-card">
<div class="restart-spinner">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
<p class="restart-title">{t('backup.restartingTitle')}</p>
<p class="restart-sub">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.backup-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.action-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.action-deck { grid-template-columns: 1fr 1fr; }
}
/* Post-restore modal */
.post-restore-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.post-restore-card {
background: var(--color-glass-elev);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-rule-strong);
border-radius: 22px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
}
.post-restore-head {
display: flex;
align-items: flex-start;
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.post-restore-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-warning-bg);
color: var(--color-warning-fg);
flex-shrink: 0;
}
.post-restore-text { min-width: 0; }
.post-restore-text h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 500;
font-size: 1.15rem;
margin: 0 0 0.25rem;
letter-spacing: -0.015em;
}
.post-restore-text p {
font-size: 0.82rem;
color: var(--color-muted-foreground);
margin: 0;
line-height: 1.45;
word-wrap: break-word;
}
.post-restore-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.post-restore-later {
padding: 0 0.95rem;
height: 34px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.post-restore-later:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
/* Restarting overlay */
.restart-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.restart-card {
text-align: center;
padding: 1.6rem 2rem;
color: var(--color-foreground);
}
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-bottom: 0.85rem;
color: var(--color-primary);
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
.restart-title {
font-family: var(--font-display);
font-style: italic;
font-size: 1.2rem;
font-weight: 500;
margin: 0;
letter-spacing: -0.015em;
}
.restart-sub {
font-size: 0.8rem;
color: var(--color-muted-foreground);
margin: 0.4rem 0 0;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.restart-spinner { animation: none !important; }
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface Props {
files: BackupFile[];
scheduled: ScheduledSettings;
pending: { pending: boolean } | null;
}
let { files, scheduled, pending }: Props = $props();
function relativeTime(iso: string | null | undefined): string {
if (!iso) return '';
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function latestCreatedAt(list: BackupFile[]): string | null {
const stamps = list
.map(f => f.created_at)
.filter((s): s is string => !!s)
.sort();
return stamps.length ? stamps[stamps.length - 1] : null;
}
function ageHours(iso: string | null): number {
if (!iso) return Infinity;
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return Infinity;
return (Date.now() - date.getTime()) / 3_600_000;
}
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
const out: Array<{ label: string; tone?: Tone }> = [];
if (pending?.pending) {
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
}
if (scheduled.backup_scheduled_enabled === 'true') {
out.push({
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
tone: 'mint',
});
} else {
out.push({ label: t('backup.scheduleOff') });
}
const latest = latestCreatedAt(files);
if (latest) {
const hours = ageHours(latest);
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
} else {
out.push({ label: t('backup.never'), tone: 'citrus' });
}
return out;
});
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb={t('crumbs.systemMaintenance')}
count={files.length}
countLabel={t('backup.countLabel')}
{pills}
/>
@@ -0,0 +1,357 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
interface Props {
files: BackupFile[];
loading: boolean;
creating: boolean;
onCreate: () => void;
onRefresh: () => void;
onDownload: (filename: string) => void;
onDelete: (filename: string) => void;
}
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseDate(iso: string | null | undefined): Date | null {
if (!iso) return null;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? null : d;
}
function relativeTime(iso: string | null | undefined): string {
const date = parseDate(iso);
if (!date) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function absoluteTime(iso: string | null | undefined): string {
const date = parseDate(iso);
return date ? date.toLocaleString() : '—';
}
function ageTone(iso: string | null | undefined): Tone {
const date = parseDate(iso);
if (!date) return 'coral';
const hours = (Date.now() - date.getTime()) / 3_600_000;
if (hours < 48) return 'mint';
if (hours < 24 * 7) return 'sky';
if (hours < 24 * 30) return 'citrus';
return 'coral';
}
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
</script>
<section class="ledger glass">
<header class="ledger-head">
<div>
<div class="ledger-eyebrow">
<MdiIcon name="mdiArchiveOutline" size={12} />
<span>{t('backup.savedFiles')}</span>
</div>
{#if files.length > 0}
<div class="ledger-summary">
<span class="ledger-count font-mono">{files.length}</span>
<span class="ledger-count-label">{t('backup.countLabel')}</span>
<span class="ledger-sep">·</span>
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
</div>
{/if}
</div>
<div class="ledger-actions">
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
{#if creating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiPlus" size={14} />
{/if}
{creating ? t('common.loading') : t('backup.createManual')}
</Button>
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
</button>
</div>
</header>
{#if files.length === 0}
<div class="ledger-empty">
<MdiIcon name="mdiCloudOffOutline" size={28} />
<p>{t('backup.noFiles')}</p>
</div>
{:else}
<ol class="ledger-list">
{#each files as file (file.filename)}
{@const tone = ageTone(file.created_at)}
<li class="row" data-tone={tone}>
<span class="row-edge" aria-hidden="true"></span>
<span class="row-dot" aria-hidden="true"></span>
<div class="row-time">
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
<span class="row-abs" title={absoluteTime(file.created_at)}>
{absoluteTime(file.created_at)}
</span>
</div>
<div class="row-name">
<span class="row-filename" title={file.filename}>{file.filename}</span>
</div>
<span class="row-size font-mono">{formatBytes(file.size)}</span>
<div class="row-actions">
<button class="icon-btn" type="button"
onclick={() => onDownload(file.filename)}
aria-label={t('backup.download')}
title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button class="icon-btn icon-btn-danger" type="button"
onclick={() => onDelete(file.filename)}
aria-label={t('common.delete')}
title={t('common.delete')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
</button>
</div>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.ledger {
padding: 1.4rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.95rem;
}
.ledger-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ledger-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.3rem;
}
.ledger-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
line-height: 1;
}
.ledger-count {
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.ledger-count-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
.ledger-total {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.ledger-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn-danger:hover:not(:disabled) {
color: var(--color-error-fg);
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
}
.spinning {
display: inline-flex;
animation: ledger-spin 1.1s linear infinite;
}
@keyframes ledger-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ledger-empty {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.6rem 1rem;
color: var(--color-muted-foreground);
text-align: center;
}
.ledger-empty p { margin: 0; font-size: 0.8rem; }
.ledger-list {
position: relative;
z-index: 1;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
position: relative;
display: grid;
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
transition: transform 0.18s, border-color 0.18s, background 0.18s;
overflow: hidden;
}
.row:hover {
transform: translateY(-1px);
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.row-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
.row-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
.row-time {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 6.5rem;
}
.row-rel {
font-size: 0.78rem;
color: var(--color-foreground);
font-weight: 500;
letter-spacing: -0.005em;
}
.row-abs {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
}
.row-name {
min-width: 0;
}
.row-filename {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row:hover .row-filename { color: var(--color-foreground); }
.row-size {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-align: right;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.15rem;
opacity: 0;
transition: opacity 0.18s;
}
.row:hover .row-actions,
.row:focus-within .row-actions { opacity: 1; }
@media (max-width: 640px) {
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
.row-time { grid-column: 2; min-width: 0; }
.row-name { grid-column: 1 / -1; }
.row-size { grid-column: 3; grid-row: 1; }
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.row { transition: none !important; }
.row:hover { transform: none !important; }
.spinning { animation: none !important; }
}
</style>
@@ -0,0 +1,392 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type SecretsMode = 'exclude' | 'masked' | 'include';
interface Props {
selectedCategories: Record<string, boolean>;
exportSecrets: SecretsMode;
exporting: boolean;
onCategoriesChange: (next: Record<string, boolean>) => void;
onSecretsChange: (next: SecretsMode) => void;
onExport: () => void;
}
let {
selectedCategories,
exportSecrets,
exporting,
onCategoriesChange,
onSecretsChange,
onExport,
}: Props = $props();
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
{
key: 'identity',
labelKey: 'backup.catGroupIdentity',
icon: 'mdiAccountNetwork',
cats: [
{ key: 'providers', labelKey: 'backup.catProviders' },
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
{ key: 'targets', labelKey: 'backup.catTargets' },
],
},
{
key: 'notif',
labelKey: 'backup.catGroupNotif',
icon: 'mdiBellOutline',
cats: [
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
],
},
{
key: 'cmd',
labelKey: 'backup.catGroupCmd',
icon: 'mdiConsoleLine',
cats: [
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
],
},
{
key: 'system',
labelKey: 'backup.catGroupSystem',
icon: 'mdiCog',
cats: [
{ key: 'actions', labelKey: 'backup.catActions' },
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
],
},
];
function toggleCat(key: string): void {
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
}
function groupState(groupKey: string): 'all' | 'none' | 'some' {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return 'none';
const flags = group.cats.map(c => !!selectedCategories[c.key]);
if (flags.every(v => v)) return 'all';
if (flags.every(v => !v)) return 'none';
return 'some';
}
function toggleGroup(groupKey: string): void {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return;
const target = groupState(groupKey) !== 'all';
const next = { ...selectedCategories };
for (const c of group.cats) next[c.key] = target;
onCategoriesChange(next);
}
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
];
</script>
<section class="export-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseExport" size={14} />
<span>{t('backup.export')}</span>
</div>
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: categories -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepCategories')}</span>
<span class="step-count">{totalSelected}</span>
</div>
<div class="group-grid">
{#each categoryGroups as group}
{@const state = groupState(group.key)}
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
<span class="group-title">{t(group.labelKey)}</span>
<span class="group-state">
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
</span>
</button>
<div class="chip-row">
{#each group.cats as cat}
<button class="chip" type="button"
class:chip-on={selectedCategories[cat.key]}
onclick={() => toggleCat(cat.key)}>
{t(cat.labelKey)}
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Step 2: secrets -->
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepSecrets')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
{#each secretsModes as mode}
<button type="button"
role="radio"
aria-checked={exportSecrets === mode.value}
class="seg"
class:seg-on={exportSecrets === mode.value}
onclick={() => onSecretsChange(mode.value)}>
<MdiIcon name={mode.icon} size={14} />
<span>{t(mode.labelKey)}</span>
</button>
{/each}
</div>
{#if exportSecrets === 'include'}
<div class="warn-strip" role="status">
<span class="warn-edge" aria-hidden="true"></span>
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
<span>{t('backup.secretsWarningExport')}</span>
</div>
{/if}
</div>
<!-- Step 3: CTA -->
<div class="step step-cta">
<Button onclick={onExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</div>
</div>
</section>
<style>
.export-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head {
position: relative;
z-index: 1;
}
.panel-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.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.step-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
}
.step-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.group-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.55rem;
}
@media (min-width: 560px) {
.group-grid { grid-template-columns: 1fr 1fr; }
}
.group {
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
padding: 0.55rem 0.65rem 0.7rem;
transition: border-color 0.18s ease, background 0.18s ease;
}
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
.group-head {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: 0;
padding: 0.15rem 0.1rem 0.4rem;
cursor: pointer;
color: var(--color-foreground);
font-family: inherit;
}
.group-icon { color: var(--color-primary); display: inline-flex; }
.group-title {
font-size: 0.74rem;
font-weight: 500;
letter-spacing: -0.005em;
flex: 1;
text-align: left;
}
.group-state {
display: inline-flex;
color: var(--color-muted-foreground);
}
.group-all .group-state { color: var(--color-primary); }
.group-some .group-state { color: var(--color-citrus); }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.chip {
font-size: 0.7rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
.chip-on {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
color: var(--color-foreground);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.warn-strip {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 10px;
font-size: 0.72rem;
line-height: 1.4;
color: var(--color-error-fg);
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
overflow: hidden;
}
.warn-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--color-coral);
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
}
</style>
@@ -0,0 +1,603 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface ValidationResult {
valid: boolean;
entity_counts?: Record<string, number>;
warnings?: string[];
errors?: string[];
}
interface ImportResult {
created?: number;
skipped?: number;
overwritten?: number;
errors?: string[];
warnings?: string[];
}
interface Props {
importFile: File | null;
importConflict: ConflictMode;
validating: boolean;
validationResult: ValidationResult | null;
importing: boolean;
importResult: ImportResult | null;
onFileSelect: (file: File | null) => void;
onConflictChange: (mode: ConflictMode) => void;
onValidate: () => void;
onImport: () => void;
}
let {
importFile,
importConflict,
validating,
validationResult,
importing,
importResult,
onFileSelect,
onConflictChange,
onValidate,
onImport,
}: Props = $props();
let dragging = $state(false);
let inputEl = $state<HTMLInputElement | undefined>();
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
];
function pickFile(): void {
inputEl?.click();
}
function handleInput(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
onFileSelect(file);
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files?.[0];
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
onFileSelect(file);
}
}
function handleDragOver(e: DragEvent): void {
e.preventDefault();
dragging = true;
}
function handleDragLeave(): void {
dragging = false;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const entityCount = $derived(
validationResult?.entity_counts
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
: 0
);
</script>
<section class="import-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseImport" size={14} />
<span>{t('backup.import')}</span>
</div>
<h3 class="panel-title">{t('backup.importDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: file -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepFile')}</span>
</div>
{#if importFile}
<div class="file-pill">
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
<div class="file-meta">
<div class="file-name" title={importFile.name}>{importFile.name}</div>
<div class="file-size">{formatBytes(importFile.size)}</div>
</div>
<button class="file-change" type="button" onclick={pickFile}>
<MdiIcon name="mdiSwapHorizontal" size={14} />
<span>{t('backup.changeFile')}</span>
</button>
</div>
{:else}
<button type="button"
class="dropzone"
class:dropzone-active={dragging}
onclick={pickFile}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}>
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
<span class="dropzone-text">
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
</span>
</button>
{/if}
<input bind:this={inputEl} type="file" accept=".json,application/json"
class="visually-hidden" onchange={handleInput} />
</div>
<!-- Step 2: validate -->
{#if importFile}
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepValidate')}</span>
{#if validationResult}
<span class="validate-pill"
class:validate-ok={validationResult.valid}
class:validate-bad={!validationResult.valid}>
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
</span>
{/if}
</div>
{#if !validationResult}
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
{:else}
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
{#if entityCount > 0}
<div class="validate-summary">
<span class="validate-count font-mono">{entityCount}</span>
<span class="validate-count-label">{t('backup.entities')}</span>
</div>
<div class="validate-categories">
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
<span class="validate-cat">
<span class="validate-cat-num font-mono">{count}</span>
<span class="validate-cat-name">{cat}</span>
</span>
{/each}
</div>
{/if}
{#if validationResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each validationResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
{#if validationResult.errors?.length}
<ul class="validate-list validate-err">
{#each validationResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Step 3: conflict mode -->
{#if importFile && validationResult?.valid}
<div class="step">
<div class="step-head">
<span class="step-num">03</span>
<span class="step-label">{t('backup.stepConflict')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
{#each conflictOptions as opt}
<button type="button"
role="radio"
aria-checked={importConflict === opt.value}
class="seg"
class:seg-on={importConflict === opt.value}
onclick={() => onConflictChange(opt.value)}>
<MdiIcon name={opt.icon} size={14} />
<span>{t(opt.labelKey)}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Step 4: CTA + results -->
<div class="step step-cta">
{#if importFile && !validationResult?.valid && !validating}
<div class="cta-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('backup.validateFirst')}</span>
</div>
{/if}
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="import-results">
<div class="result-tiles">
<div class="result-tile tile-created">
<span class="result-num font-mono">{importResult.created ?? 0}</span>
<span class="result-label">{t('backup.resultCreated')}</span>
</div>
<div class="result-tile tile-skipped">
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
<span class="result-label">{t('backup.resultSkipped')}</span>
</div>
<div class="result-tile tile-overwritten">
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
<span class="result-label">{t('backup.resultOverwritten')}</span>
</div>
</div>
{#if importResult.errors?.length}
<ul class="validate-list validate-err">
{#each importResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
{#if importResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each importResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
</section>
<style>
.import-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head { position: relative; z-index: 1; }
.panel-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.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step { display: flex; flex-direction: column; gap: 0.6rem; }
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
}
/* Drop zone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 1.65rem 1.1rem;
border-radius: 16px;
border: 1.5px dashed var(--color-rule-strong);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
text-align: center;
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
min-height: 140px;
}
.dropzone:hover {
color: var(--color-foreground);
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.dropzone-active {
color: var(--color-foreground);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
transform: scale(1.005);
}
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
.dropzone-text { line-height: 1.4; max-width: 28ch; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* File pill */
.file-pill {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.file-icon { color: var(--color-primary); flex-shrink: 0; }
.file-meta { flex: 1; min-width: 0; }
.file-name {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.66rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.file-change {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.32rem 0.65rem;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.7rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
/* Validation */
.validate-pill {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-weight: 500;
}
.validate-ok {
color: var(--color-success-fg);
background: var(--color-success-bg);
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
}
.validate-bad {
color: var(--color-error-fg);
background: var(--color-error-bg);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
}
.validate-card {
padding: 0.7rem 0.85rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.validate-card-bad {
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
}
.validate-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
}
.validate-count {
font-size: 1.4rem;
font-weight: 500;
color: var(--color-foreground);
line-height: 1;
}
.validate-count-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.validate-categories {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.validate-cat {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass);
font-size: 0.66rem;
}
.validate-cat-num {
color: var(--color-primary);
font-weight: 500;
}
.validate-cat-name {
color: var(--color-muted-foreground);
}
.validate-list {
list-style: none;
padding: 0; margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.validate-list li {
display: flex;
align-items: flex-start;
gap: 0.35rem;
font-size: 0.7rem;
line-height: 1.4;
}
.validate-warn li { color: var(--color-warning-fg); }
.validate-err li { color: var(--color-error-fg); }
/* Segmented (same vocabulary as ExportPanel) */
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.cta-hint {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.65rem;
}
.import-results {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.result-tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
}
.result-tile {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.6rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.result-num {
font-size: 1.2rem;
font-weight: 500;
line-height: 1;
color: var(--color-foreground);
}
.result-label {
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted-foreground);
}
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
.tile-created .result-num { color: var(--color-mint); }
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
.tile-skipped .result-num { color: var(--color-sky); }
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
.tile-overwritten .result-num { color: var(--color-citrus); }
</style>
@@ -0,0 +1,136 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
interface Props {
pending: PendingState | null;
onApply: () => void;
onCancel: () => void;
}
let { pending, onApply, onCancel }: Props = $props();
</script>
{#if pending?.pending}
<div class="pending-strip animate-rise" role="alert">
<span class="pending-edge" aria-hidden="true"></span>
<span class="aurora-pulse error" aria-hidden="true"></span>
<div class="pending-body">
<div class="pending-title">
<MdiIcon name="mdiShieldAlertOutline" size={16} />
<span>{t('backup.pendingTitle')}</span>
</div>
<div class="pending-meta">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
<span class="pending-dot">·</span>
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
</div>
</div>
<div class="pending-actions">
{#if pending.supervised}
<Button size="sm" onclick={onApply}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button class="pending-cancel" onclick={onCancel} type="button">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<style>
.pending-strip {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
margin-bottom: 1.25rem;
border-radius: 18px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
box-shadow:
var(--shadow-card),
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
overflow: hidden;
flex-wrap: wrap;
}
.pending-strip::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.35;
}
.pending-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
}
.pending-body {
position: relative;
z-index: 1;
flex: 1;
min-width: 12rem;
}
.pending-title {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.01em;
}
.pending-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
margin-top: 0.18rem;
word-break: break-word;
}
.pending-dot {
opacity: 0.6;
margin: 0 0.25rem;
}
.pending-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.pending-cancel {
padding: 0 0.95rem;
height: 34px;
font-size: 0.82rem;
border-radius: 12px;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.pending-cancel:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
enabled: boolean;
intervalHours: string;
secretsMode: string;
retentionCount: string;
saving: boolean;
onToggle: () => void;
onSave: () => void;
}
let {
enabled,
intervalHours = $bindable(),
secretsMode = $bindable(),
retentionCount = $bindable(),
saving,
onToggle,
onSave,
}: Props = $props();
const intervalItems: GridItem[] = $derived([
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
]);
const secretsItems: GridItem[] = $derived([
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
]);
const retentionItems: GridItem[] = $derived([
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
]);
</script>
<section class="cassette glass" class:cassette-on={enabled}>
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
<span class="toggle-track" class:toggle-on={enabled}>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">
<span class="cassette-eyebrow">
<MdiIcon name="mdiClockOutline" size={12} />
<span>{t('backup.scheduled')}</span>
</span>
<span class="cassette-title">{t('backup.enableScheduled')}</span>
</span>
</button>
{#if enabled}
<div class="cassette-controls">
<div class="ctl">
<span class="ctl-label">{t('backup.interval')}</span>
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.secretsMode')}</span>
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.retention')}</span>
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
</div>
</div>
{:else}
<div class="cassette-off">{t('backup.scheduleOff')}</div>
{/if}
<div class="cassette-save">
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
<MdiIcon name="mdiContentSave" size={14} />
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
</section>
<style>
.cassette {
display: flex;
align-items: stretch;
gap: 1.1rem;
padding: 0.95rem 1.15rem;
flex-wrap: wrap;
}
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
.cassette-toggle {
display: flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
cursor: pointer;
font-family: inherit;
color: var(--color-foreground);
text-align: left;
padding: 0.2rem 0.1rem;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
.cassette-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.cassette-title {
font-size: 0.85rem;
font-weight: 500;
font-family: var(--font-display);
font-style: italic;
letter-spacing: -0.005em;
color: var(--color-foreground);
}
.cassette-controls {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem;
flex: 1;
min-width: 0;
}
@media (min-width: 720px) {
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.ctl-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.cassette-off {
flex: 1;
display: flex;
align-items: center;
font-size: 0.78rem;
color: var(--color-muted-foreground);
font-style: italic;
font-family: var(--font-display);
position: relative;
z-index: 1;
}
.cassette-save {
display: flex;
align-items: flex-end;
position: relative;
z-index: 1;
flex-shrink: 0;
}
@media (max-width: 720px) {
.cassette-save { width: 100%; }
.cassette-save > :global(*) { width: 100%; }
}
</style>
+314 -51
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
@@ -13,7 +15,6 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -22,6 +23,7 @@
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
@@ -164,6 +166,20 @@
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// Per-target expansion state for the receivers section. Hidden by default.
let expandedTargets = $state<Set<number>>(new SvelteSet());
function isExpanded(id: number): boolean {
return expandedTargets.has(id);
}
function toggleExpanded(id: number) {
if (expandedTargets.has(id)) expandedTargets.delete(id);
else expandedTargets.add(id);
}
function expandTarget(id: number) {
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// Reset form when switching target type tabs
@@ -179,6 +195,98 @@
onMount(load);
// ── Bot grouping ──
type TargetGroup = {
key: string;
type: string;
name: string;
subtitle: string | null;
icon: string;
typeBadge: string | null;
botHref: string | null;
botEntityId: number | null;
muted: boolean;
targets: NotificationTarget[];
};
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
const groupedTargets = $derived.by<TargetGroup[]>(() => {
const groups = new Map<string, TargetGroup>();
for (const tgt of targets) {
const isBotType = BOT_TYPES.has(tgt.type);
const botId = isBotType ? getBotEntityId(tgt) : null;
const key = isBotType
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
: `${tgt.type}:direct`;
let group = groups.get(key);
if (!group) {
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
let name = '';
let subtitle: string | null = null;
let muted = false;
if (isBotType && botId) {
if (tgt.type === 'telegram') {
const bot = telegramBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
icon = bot?.icon || 'mdiSend';
} else if (tgt.type === 'email') {
const bot = emailBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.email || null;
icon = bot?.icon || 'mdiEmailOutline';
} else if (tgt.type === 'matrix') {
const bot = matrixBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.display_name || bot?.homeserver_url || null;
icon = bot?.icon || 'mdiMatrix';
}
} else if (isBotType) {
name = t('targets.groupNoBot');
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
muted = true;
} else {
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
subtitle = t('targets.groupDirect');
muted = true;
}
group = {
key,
type: tgt.type,
name,
subtitle,
icon,
typeBadge,
botHref: isBotType && botId ? getBotHref(tgt) : null,
botEntityId: isBotType ? botId : null,
muted,
targets: [],
};
groups.set(key, group);
}
group.targets.push(tgt);
}
const rank = (g: TargetGroup) => {
if (g.type === 'broadcast') return 4;
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
return 1; // bot-linked
};
return [...groups.values()].sort((a, b) => {
const ra = rank(a), rb = rank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name);
});
});
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
@@ -216,6 +324,16 @@
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
async function discoverReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
function openNew() {
@@ -341,15 +459,27 @@
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
if (addingReceiverForTarget === targetId) {
addingReceiverForTarget = null;
await tick();
}
addingReceiverForTarget = targetId;
expandTarget(targetId);
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
// Show what we have immediately (cached list), then actively discover in the
// background so any newly-added chats appear in the palette as soon as
// Telegram returns them.
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
if (botId) {
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
discoverReceiverBotChats(botId);
}
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
@@ -453,7 +583,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
crumb={t('crumbs.routingTargets')}
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
@@ -510,53 +640,84 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.type === 'broadcast' && target.child_targets?.length}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list -->
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
<div class="targets-list">
{#each groupedTargets as group (group.key)}
<section class="target-group">
<BotGroupHeader
icon={group.icon}
name={group.name}
subtitle={group.subtitle}
targetCount={group.targets.length}
typeBadge={!activeType ? group.typeBadge : null}
botHref={group.botHref}
botEntityId={group.botEntityId}
muted={group.muted}
/>
</Card>
<div class="target-group__items stagger-children">
{#each group.targets as target (target.id)}
{@const expanded = isExpanded(target.id)}
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="target-summary"
aria-expanded={expanded}
aria-controls={`target-body-${target.id}`}
onclick={() => toggleExpanded(target.id)}
>
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
<MdiIcon name="mdiChevronRight" size={16} />
</span>
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<span class="target-summary__name">{target.name}</span>
{#if childCount > 0}
<span class="target-summary__count">
<span class="target-summary__count-num">{childCount}</span>
<span class="target-summary__count-label">{childLabel}</span>
</span>
{:else}
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
{/if}
</button>
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list (collapsible) -->
{#if expanded}
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</div>
{/if}
</Card>
{/each}
</div>
</section>
{/each}
</div>
{/if}
@@ -578,3 +739,105 @@
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.targets-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.target-group {
display: block;
}
.target-group__items {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-left: 0.85rem;
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
margin-left: 0.55rem;
}
@media (max-width: 640px) {
.target-group__items {
padding-left: 0.4rem;
margin-left: 0.25rem;
}
}
.target-summary {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.1rem 0.25rem 0.1rem 0;
margin: -0.1rem 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: 8px;
transition: background 0.15s ease;
}
.target-summary:hover {
background: var(--color-glass-strong);
}
.target-summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.target-summary__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
}
.target-summary__chevron.open {
transform: rotate(90deg);
color: var(--color-primary);
}
.target-summary__icon {
color: var(--color-primary);
display: inline-flex;
flex-shrink: 0;
}
.target-summary__name {
font-weight: 500;
font-size: 0.95rem;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.target-summary__count {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.12rem 0.45rem;
border-radius: 9999px;
background: var(--color-muted);
flex-shrink: 0;
}
.target-summary__count-num {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-foreground);
}
.target-summary__count-label {
text-transform: lowercase;
}
.target-summary__count--empty {
font-style: italic;
font-family: inherit;
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: transparent;
padding: 0.12rem 0.2rem;
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts">
import MdiIcon from '$lib/components/MdiIcon.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { t } from '$lib/i18n';
interface Props {
icon: string;
name: string;
subtitle?: string | null;
targetCount: number;
typeBadge?: string | null;
botHref?: string | null;
botEntityId?: number | null;
muted?: boolean;
}
let {
icon,
name,
subtitle = null,
targetCount,
typeBadge = null,
botHref = null,
botEntityId = null,
muted = false,
}: Props = $props();
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
</script>
<div class="bot-group-header" class:muted>
<div class="bot-avatar">
<MdiIcon name={icon} size={18} />
</div>
<div class="bot-meta">
<div class="bot-title-row">
<span class="bot-name">{name}</span>
{#if typeBadge}
<span class="type-badge">{typeBadge}</span>
{/if}
</div>
{#if subtitle}
<span class="bot-sub">{subtitle}</span>
{/if}
</div>
<div class="bot-actions">
<span class="count-chip">
<span class="count-num">{targetCount}</span>
<span class="count-label">{countLabel}</span>
</span>
{#if botHref}
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
{/if}
</div>
</div>
<style>
.bot-group-header {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
margin: 1.4rem 0 0.55rem 0;
border-radius: 14px;
background: linear-gradient(
95deg,
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
var(--color-glass) 75%
);
border: 1px solid var(--color-rule-strong);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
overflow: hidden;
}
.bot-group-header::before {
content: '';
position: absolute;
left: 0;
top: 12%;
bottom: 12%;
width: 3px;
border-radius: 0 4px 4px 0;
background: linear-gradient(
180deg,
var(--color-primary),
color-mix(in srgb, var(--color-primary) 35%, transparent)
);
}
.bot-group-header.muted {
background: var(--color-glass);
}
.bot-group-header.muted::before {
background: var(--color-rule-strong);
}
.bot-avatar {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.muted .bot-avatar {
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border-color: var(--color-rule-strong);
}
.bot-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.bot-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.bot-name {
font-size: 0.92rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.type-badge {
font-size: 0.6rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--color-muted);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.bot-sub {
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bot-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.count-chip {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.18rem 0.5rem;
border-radius: 9999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
}
.count-num {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
}
.count-label {
text-transform: lowercase;
}
.bot-group-header:first-child {
margin-top: 0;
}
</style>
@@ -114,34 +114,37 @@
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if addingReceiverForTarget === target.id}
<EntitySelect
items={chatItems}
bind:value={receiverForm.chat_id}
open={true}
showTrigger={false}
placeholder={t('telegramBot.selectChat')}
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
onclose={oncancelReceiver}
/>
{/if}
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{:else if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => onloadBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
{#if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
@@ -261,7 +261,25 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
// config in edit mode. Mirrors the same hook on tracking-configs so the
// Notification Tracker form can link directly to the editor instead of
// the generic list. Strips the param afterwards so a browser refresh
// doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allTemplateConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
}
/**
@@ -429,7 +447,7 @@
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
@@ -276,7 +276,7 @@
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
+1 -1
View File
@@ -93,7 +93,7 @@
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.7.0"
version = "0.7.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.7.0"
version = "0.7.2"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -403,7 +403,13 @@ async def get_command_variables(
webhook = {
"status": {
"description": "/status webhook provider summary",
"variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"},
"variables": {
**common_vars,
"trackers_active": "Number of enabled trackers attached to the webhook provider",
"trackers_total": "Total number of trackers attached to the webhook provider",
"provider_name": "Webhook provider name",
"last_event": "Last event timestamp ('YYYY-MM-DD HH:MM' or '-')",
},
},
}
@@ -118,6 +118,31 @@ async def get_status(
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
# Live-resolve command tracker and bot names for command_* events
# (mirrors the action/tracker pattern above). Falls back to the
# snapshot stored on the EventLog when the entity has been deleted.
cmd_tracker_ids = {
e.command_tracker_id for e in event_rows if e.command_tracker_id is not None
}
cmd_tracker_name_map: dict[int, str] = {}
if cmd_tracker_ids:
cmd_tracker_rows = (await session.exec(
select(CommandTracker.id, CommandTracker.name).where(
CommandTracker.id.in_(cmd_tracker_ids)
)
)).all()
cmd_tracker_name_map = {tid: tname for tid, tname in cmd_tracker_rows}
bot_ids = {
e.telegram_bot_id for e in event_rows if e.telegram_bot_id is not None
}
bot_name_map: dict[int, str] = {}
if bot_ids:
bot_rows = (await session.exec(
select(TelegramBot.id, TelegramBot.name).where(TelegramBot.id.in_(bot_ids))
)).all()
bot_name_map = {bid: bname for bid, bname in bot_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
@@ -135,11 +160,30 @@ async def get_status(
return f"(deleted) {e.action_name}"
return ""
def _display_command_tracker_name(e: EventLog) -> str:
if (
e.command_tracker_id is not None
and e.command_tracker_id in cmd_tracker_name_map
):
return cmd_tracker_name_map[e.command_tracker_id]
if e.command_tracker_name:
return f"(deleted) {e.command_tracker_name}"
return ""
def _display_bot_name(e: EventLog) -> str:
if e.telegram_bot_id is not None and e.telegram_bot_id in bot_name_map:
return bot_name_map[e.telegram_bot_id]
if e.bot_name:
return f"(deleted) {e.bot_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
For command events the ``collection_name`` already stores the
rendered ``/cmd args`` string so we just pass it through.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
@@ -155,9 +199,14 @@ async def get_status(
"id": e.id,
"event_type": e.event_type,
"collection_name": _display_subject(e),
"tracker_id": e.tracker_id,
"tracker_name": _display_tracker_name(e),
"action_id": e.action_id,
"action_name": _display_action_name(e),
"command_tracker_id": e.command_tracker_id,
"command_tracker_name": _display_command_tracker_name(e),
"telegram_bot_id": e.telegram_bot_id,
"bot_name": _display_bot_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
@@ -33,11 +33,13 @@ def _auto_register() -> None:
from .gitea_handler import GiteaCommandHandler
from .planka_handler import PlankaCommandHandler
from .nut_handler import NutCommandHandler
from .webhook_handler import WebhookCommandHandler
register_handler(ImmichCommandHandler())
register_handler(GiteaCommandHandler())
register_handler(PlankaCommandHandler())
register_handler(NutCommandHandler())
register_handler(WebhookCommandHandler())
# Auto-register on import
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
return sorted(enabled), merged_limits
# ---------------------------------------------------------------------------
# Event logging
# ---------------------------------------------------------------------------
def _format_command_subject(cmd: str, args: str) -> str:
"""Render the dashboard ``collection_name`` for a command event."""
args = (args or "").strip()
return f"/{cmd} {args}".rstrip() if args else f"/{cmd}"
def _normalize_issuer(issuer: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip a Telegram ``from`` payload to the fields the dashboard needs.
Telegram's ``from`` carries plenty we don't want to persist (premium
badge, language code already captured elsewhere, etc.). Keep just
the identity bits and drop anything else so future Telegram changes
can't accidentally start logging extra PII.
"""
if not issuer:
return None
keep = ("id", "username", "first_name", "last_name", "is_bot")
out = {k: issuer[k] for k in keep if k in issuer and issuer[k] not in (None, "")}
return out or None
async def _log_command_event(
*,
bot: TelegramBot,
chat_id: str,
cmd: str,
args: str,
locale: str,
event_type: str,
responses: list[CommandResponse],
ctx_tuples: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
],
extra_details: dict[str, Any] | None = None,
issuer: dict[str, Any] | None = None,
) -> None:
"""Persist a single ``EventLog`` row for a bot-command invocation.
One row per user invocation. Per-tracker breakdown lives in ``details``
(``tracker_count`` / ``responses_count``). Best-effort: a logging
failure must never block the user-visible reply, so we swallow.
"""
try:
first_tracker: CommandTracker | None = None
first_provider: ServiceProvider | None = None
if ctx_tuples:
first_tracker, _, first_provider, _ = ctx_tuples[0]
media_total = sum(len(r.media or []) for r in responses)
details: dict[str, Any] = {
"command": cmd,
"args": args or "",
"chat_id": chat_id,
"locale": locale,
"tracker_count": len(ctx_tuples),
"responses_count": len(responses),
}
normalized_issuer = _normalize_issuer(issuer)
if normalized_issuer:
details["issuer"] = normalized_issuer
if extra_details:
details.update(extra_details)
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=bot.user_id,
tracker_id=None,
tracker_name="",
action_id=None,
action_name="",
command_tracker_id=first_tracker.id if first_tracker else None,
command_tracker_name=first_tracker.name if first_tracker else "",
telegram_bot_id=bot.id,
bot_name=bot.name or "",
provider_id=first_provider.id if first_provider else None,
provider_name=(first_provider.name if first_provider else "") or "",
event_type=event_type,
collection_id=str(chat_id),
collection_name=_format_command_subject(cmd, args),
assets_count=media_total,
details=details,
))
await session.commit()
except Exception: # noqa: BLE001 — diagnostic only, never block reply
_LOGGER.exception(
"Failed to log command event bot=%d chat=%s cmd=/%s",
bot.id, chat_id, cmd,
)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
@@ -271,12 +366,18 @@ async def handle_command(
chat_id: str,
text: str,
language_code: str = "",
*,
issuer: dict[str, Any] | None = None,
) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers.
Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config.
``issuer`` is the Telegram ``from`` object (``{id, username,
first_name, last_name, language_code}``) when known. Stored on the
EventLog row so the dashboard can show *who* invoked the command.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
@@ -292,10 +393,20 @@ async def handle_command(
# Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id)
# Universal commands have no tracker/provider context.
if cmd == "start":
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=[], issuer=issuer,
)
return responses
# Unknown / disabled command — caller treats this the same as "no
# match" and we deliberately do NOT log it (avoids dashboard spam
# from random ``/foo`` traffic).
if cmd not in enabled and cmd != "start":
return None
@@ -307,13 +418,26 @@ async def handle_command(
cmd, bot.id, chat_id, wait,
)
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_rate_limited", responses=responses,
ctx_tuples=ctx_tuples, extra_details={"wait_seconds": wait},
issuer=issuer,
)
return responses
# Universal commands — single merged response
if cmd == "help":
ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=ctx_tuples, issuer=issuer,
)
return responses
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
@@ -329,48 +453,69 @@ async def handle_command(
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
dispatched_ctx: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
] = []
try:
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
if result is not None:
responses.append(result)
dispatched_ctx.append((tracker, config, provider, listener))
except Exception as exc: # noqa: BLE001 — log then re-raise
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_failed", responses=responses,
ctx_tuples=ctx_tuples,
extra_details={"error": f"{type(exc).__name__}: {exc}"},
issuer=issuer,
)
if result is not None:
responses.append(result)
raise
return responses if responses else None
if responses:
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=dispatched_ctx, issuer=issuer,
)
return responses
return None
def _cmd_help(
@@ -120,7 +120,11 @@ async def telegram_webhook(
async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text),
):
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
responses = await handle_command(
bot, chat_id, text,
language_code=effective_lang,
issuer=from_user or None,
)
if not responses:
_LOGGER.info(
"Command produced no response (cmd=%r) after %.0f ms",
@@ -0,0 +1,89 @@
"""Generic webhook provider bot command handler.
The generic webhook provider has no upstream API to query its only
runtime signal is the stream of incoming webhook payloads recorded as
``EventLog`` rows. ``/status`` therefore reports DB-derived stats:
* ``trackers_active`` enabled ``NotificationTracker`` rows for the provider
* ``trackers_total`` all ``NotificationTracker`` rows for the provider
* ``provider_name`` the provider's display name
* ``last_event`` formatted timestamp of the most recent received event,
or ``-`` if nothing has been received yet
"""
from __future__ import annotations
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
NotificationTracker,
ServiceProvider,
TelegramBot,
)
from .base import CommandResponse, ProviderCommandHandler
from .command_utils import get_last_event_str
from .handler import _render_cmd_template
_WEBHOOK_COMMANDS = {"status"}
async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
"""Build the context for ``/status`` on a webhook provider."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider.id
)
)
trackers = list(result.all())
active = [t for t in trackers if t.enabled]
last_str = await get_last_event_str([t.id for t in active])
return {
"trackers_active": len(active),
"trackers_total": len(trackers),
"provider_name": provider.name or "",
"last_event": last_str,
}
class WebhookCommandHandler(ProviderCommandHandler):
"""Handles ``/status`` for generic-webhook providers."""
provider_type = "webhook"
def get_provider_commands(self) -> set[str]:
return _WEBHOOK_COMMANDS
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — webhook has no album scope
page: int = 1,
) -> CommandResponse | None:
if cmd != "status":
return None
ctx = await _cmd_status(provider)
return CommandResponse(
text=_render_cmd_template(cmd_templates, "status", locale, ctx),
)
@@ -90,6 +90,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
@@ -105,6 +109,8 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
("ix_event_log_command_tracker_id", "command_tracker_id"),
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
@@ -519,6 +519,17 @@ class EventLog(SQLModel, table=True):
default=None, foreign_key="action.id", index=True,
)
action_name: str = Field(default="")
# Bot command provenance. Populated when ``event_type`` starts with
# ``command_`` so the dashboard can render command activity alongside
# tracker and action events. NULL for non-command rows.
command_tracker_id: int | None = Field(
default=None, foreign_key="command_tracker.id", index=True,
)
command_tracker_name: str = Field(default="")
telegram_bot_id: int | None = Field(
default=None, foreign_key="telegram_bot.id", index=True,
)
bot_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="")
event_type: str = Field(index=True)
@@ -232,7 +232,9 @@ async def _poll_bot(bot_id: int) -> None:
# Copy attributes before session closes to avoid detached-instance errors
from types import SimpleNamespace
bot_token = bot.token
bot_obj = SimpleNamespace(id=bot.id, name=bot.name, token=bot.token)
bot_obj = SimpleNamespace(
id=bot.id, name=bot.name, token=bot.token, user_id=bot.user_id,
)
offset = _last_update_id.get(bot_id, 0)
@@ -331,7 +333,11 @@ async def _poll_bot(bot_id: int) -> None:
async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text),
):
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
responses = await handle_command(
bot_obj, chat_id, text,
language_code=effective_lang,
issuer=from_user or None,
)
if not responses:
_LOGGER.info(
"Command produced no response (cmd=%r, poll) after %.0f ms",
@@ -0,0 +1,227 @@
"""Bot command invocations must be logged to ``EventLog``.
Covers the three branches in ``handle_command``:
* ``command_handled`` a successful invocation (here exercised via the
helper directly so the test stays focused on the persistence shape).
* ``command_rate_limited`` caller hit the cooldown.
* ``command_failed`` an exception bubbled out of dispatch.
The dashboard reads these rows via ``GET /api/status`` so the test also
asserts the row is filterable by ``event_type=command_*``.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
def _bootstrap_app():
"""Bring up the app once so migrations run against the temp DB."""
from notify_bridge_server.main import app
return app
async def _seed_user_and_bot(name: str = "Test bot"):
"""Create a User + TelegramBot, return the bot row."""
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import TelegramBot, User
engine = get_engine()
async with AsyncSession(engine) as session:
user = User(username=f"u_{name}", hashed_password="x")
session.add(user)
await session.commit()
await session.refresh(user)
bot = TelegramBot(user_id=user.id, name=name, token="dummy")
session.add(bot)
await session.commit()
await session.refresh(bot)
return bot
async def _read_events(event_type: str, bot_id: int):
"""Filter by bot_id so tests don't leak rows into each other.
The temp DB is shared across tests in this module without this
filter a row left by an earlier test would make the next assertion
flaky depending on collection order.
"""
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import EventLog
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog)
.where(EventLog.event_type == event_type)
.where(EventLog.telegram_bot_id == bot_id)
)
return list(result.all())
def test_format_command_subject_no_args(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.commands.handler import _format_command_subject
assert _format_command_subject("latest", "") == "/latest"
assert _format_command_subject("help", None) == "/help"
def test_format_command_subject_with_args(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.commands.handler import _format_command_subject
assert _format_command_subject("search", "sunset") == "/search sunset"
# Trailing whitespace must not leak into the dashboard label.
assert _format_command_subject("search", "sunset ") == "/search sunset"
def test_normalize_issuer_keeps_identity_drops_extras(tmp_data_dir) -> None: # noqa: ARG001
"""Telegram ``from`` is whitelisted to identity fields only."""
from notify_bridge_server.commands.handler import _normalize_issuer
assert _normalize_issuer(None) is None
assert _normalize_issuer({}) is None
raw = {
"id": 1234,
"username": "alex",
"first_name": "Alex",
"last_name": "",
"language_code": "ru", # already captured separately — must drop
"is_premium": True, # must not leak into our log
}
assert _normalize_issuer(raw) == {
"id": 1234,
"username": "alex",
"first_name": "Alex",
}
def test_log_command_handled_persists_row(tmp_data_dir) -> None: # noqa: ARG001
"""``command_handled`` row carries bot + provenance + media count."""
import asyncio
from notify_bridge_server.commands.base import CommandResponse
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("HandledBot")
await _log_command_event(
bot=bot,
chat_id="123456",
cmd="latest",
args="",
locale="en",
event_type="command_handled",
responses=[CommandResponse(text="ok", media=[{"type": "photo"}])],
ctx_tuples=[], # universal command path: no tracker context
)
rows = await _read_events("command_handled", bot.id)
assert len(rows) == 1
row = rows[0]
assert row.user_id == bot.user_id
assert row.telegram_bot_id == bot.id
assert row.bot_name == "HandledBot"
assert row.collection_id == "123456"
assert row.collection_name == "/latest"
assert row.assets_count == 1
assert row.details["command"] == "latest"
assert row.details["chat_id"] == "123456"
assert row.details["responses_count"] == 1
asyncio.run(run())
def test_log_command_rate_limited_carries_wait_seconds(tmp_data_dir) -> None: # noqa: ARG001
import asyncio
from notify_bridge_server.commands.base import CommandResponse
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("ThrottledBot")
await _log_command_event(
bot=bot,
chat_id="42",
cmd="random",
args="",
locale="en",
event_type="command_rate_limited",
responses=[CommandResponse(text="cooldown")],
ctx_tuples=[],
extra_details={"wait_seconds": 7},
)
rows = await _read_events("command_rate_limited", bot.id)
assert len(rows) == 1
assert rows[0].details["wait_seconds"] == 7
assert rows[0].assets_count == 0 # text-only response
asyncio.run(run())
def test_log_command_failed_records_error(tmp_data_dir) -> None: # noqa: ARG001
import asyncio
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("BrokenBot")
await _log_command_event(
bot=bot,
chat_id="9",
cmd="albums",
args="",
locale="ru",
event_type="command_failed",
responses=[],
ctx_tuples=[],
extra_details={"error": "RuntimeError: boom"},
)
rows = await _read_events("command_failed", bot.id)
assert len(rows) == 1
assert rows[0].details["error"] == "RuntimeError: boom"
assert rows[0].details["locale"] == "ru"
asyncio.run(run())
def test_log_command_event_handles_db_error_gracefully(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A logging failure must NOT raise — the user still gets their reply."""
import asyncio
from notify_bridge_server.commands import handler as handler_mod
from notify_bridge_server.commands.base import CommandResponse
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("StillRepliesBot")
def boom() -> object:
raise RuntimeError("db gone")
monkeypatch.setattr(handler_mod, "get_engine", boom)
# Must not raise.
await handler_mod._log_command_event(
bot=bot,
chat_id="1",
cmd="help",
args="",
locale="en",
event_type="command_handled",
responses=[CommandResponse(text="hi")],
ctx_tuples=[],
)
asyncio.run(run())
@@ -0,0 +1,195 @@
"""Tests for the generic-webhook ``/status`` command handler.
The webhook provider has no upstream API, so ``/status`` is built from
local DB stats:
* ``trackers_active`` count of enabled ``NotificationTracker`` rows
* ``trackers_total`` count of all ``NotificationTracker`` rows
* ``last_event`` formatted timestamp of the most recent ``EventLog`` row
tied to one of the provider's trackers, or ``-`` when there are none
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlmodel.ext.asyncio.session import AsyncSession
_STATUS_TEMPLATE_EN = (
"Webhook Status\n"
"Trackers active: {{ trackers_active }}/{{ trackers_total }}\n"
"Provider: {{ provider_name }}\n"
"Last event: {{ last_event }}"
)
def _bootstrap_app():
"""Bring up the app once so migrations run against the temp DB."""
from notify_bridge_server.main import app
return app
async def _seed_user() -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import User
engine = get_engine()
async with AsyncSession(engine) as session:
user = User(username=f"u_{datetime.now(timezone.utc).timestamp()}", hashed_password="x")
session.add(user)
await session.commit()
await session.refresh(user)
return user.id
async def _seed_provider(user_id: int, name: str = "WH") -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import ServiceProvider
engine = get_engine()
async with AsyncSession(engine) as session:
prov = ServiceProvider(user_id=user_id, type="webhook", name=name, config={})
session.add(prov)
await session.commit()
await session.refresh(prov)
return prov.id
async def _seed_tracker(user_id: int, provider_id: int, name: str, *, enabled: bool) -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import NotificationTracker
engine = get_engine()
async with AsyncSession(engine) as session:
tr = NotificationTracker(
user_id=user_id, provider_id=provider_id, name=name, enabled=enabled,
)
session.add(tr)
await session.commit()
await session.refresh(tr)
return tr.id
async def _seed_event(tracker_id: int, when: datetime) -> None:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import EventLog
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
tracker_id=tracker_id,
tracker_name="webhook-tr",
event_type="webhook_received",
collection_id="",
collection_name="",
created_at=when,
))
await session.commit()
async def _load_provider(provider_id: int):
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import ServiceProvider
engine = get_engine()
async with AsyncSession(engine) as session:
return await session.get(ServiceProvider, provider_id)
def test_dispatch_registers_webhook_handler(tmp_data_dir) -> None: # noqa: ARG001
"""Auto-registration must wire the webhook handler to provider type 'webhook'."""
from notify_bridge_server.commands.dispatch import get_handler
handler = get_handler("webhook")
assert handler is not None, "WebhookCommandHandler must be registered"
assert handler.provider_type == "webhook"
assert "status" in handler.get_provider_commands()
def test_status_renders_active_total_and_last_event(tmp_data_dir) -> None: # noqa: ARG001
"""``/status`` returns active/total counts and formatted last-event timestamp."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH1")
enabled_id = await _seed_tracker(user_id, provider_id, "tr-on", enabled=True)
await _seed_tracker(user_id, provider_id, "tr-off", enabled=False)
event_at = datetime(2026, 5, 1, 12, 34, tzinfo=timezone.utc)
await _seed_event(enabled_id, event_at)
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
response = await handler.handle(
cmd="status", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates=templates,
bot=None, tracker=None, config=None,
)
assert response is not None
assert response.text is not None
text = response.text
assert "Trackers active: 1/2" in text
assert "Provider: WH1" in text
assert "2026-05-01 12:34" in text
asyncio.run(run())
def test_status_with_no_events_shows_dash(tmp_data_dir) -> None: # noqa: ARG001
"""Zero events → ``last_event`` renders as '-' (the get_last_event_str sentinel)."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH-empty")
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
response = await handler.handle(
cmd="status", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates=templates,
bot=None, tracker=None, config=None,
)
assert response is not None
assert "Trackers active: 0/0" in response.text
assert "Last event: -" in response.text
asyncio.run(run())
def test_status_returns_none_for_unknown_command(tmp_data_dir) -> None: # noqa: ARG001
"""Commands the webhook handler doesn't own must return None (lets dispatch fall through)."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH-noop")
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
response = await handler.handle(
cmd="albums", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates={},
bot=None, tracker=None, config=None,
)
assert response is None
asyncio.run(run())