Compare commits

...

12 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
alexei.dolgolyov 632e4c1aa3 chore: release v0.7.0
Release / release (push) Successful in 1m19s
2026-05-07 14:03:48 +03:00
alexei.dolgolyov 0eb899afb9 feat: harden notification stack and switch logging selectors to icon grid
Notifications:
- Add shared http_base, redact, and SSRF hardening modules
- Refactor dispatcher, queue, receiver and per-provider clients
  (telegram, discord, email, matrix, ntfy, slack, webhook) to use
  the shared base, with bounded queue and redacted error logs
- Tests for ssrf, redact, http_base, queue bounds, dispatcher
  aggregation, telegram media partition, email and matrix clients

Frontend:
- Settings: log level / log format selectors now use IconGridSelect
  with per-option icons and i18n descriptions
- Minor providers page and entity-cache store updates

Tooling:
- Document code-review-graph MCP usage in CLAUDE.md
- Ignore .code-review-graph/, register .mcp.json
2026-05-07 13:53:26 +03:00
alexei.dolgolyov 5bd63a2191 feat(frontend): autogenerate entity names from type/provider
Mirror the providers form pattern (defaultName tied to type) across
bots, targets, trackers, actions, and configs. Each form now derives
form.name from the selected type or provider while the user hasn't
manually edited it; switching to edit-mode flips the manualEdited
flag so existing names are preserved.

Defaults: bots → "<Type> Bot"; targets → type label; notification
trackers → "<provider> Tracker"; command trackers → "<provider>
Commands"; actions → "<provider> <Action Type>"; tracking/template/
command/command-template configs → "<descriptor.defaultName>
<Suffix>". TargetForm and TrackerForm grew an optional onnameinput
prop so parents can flag manual edits in subform inputs.
2026-05-07 13:01:52 +03:00
83 changed files with 8826 additions and 1855 deletions
+2
View File
@@ -56,3 +56,5 @@ frontend/.svelte-kit/
# Logs # Logs
*.log *.log
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -43,3 +43,42 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
- Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`) - Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`)
- Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`) - Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`)
- Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py` - Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py`
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+13 -20
View File
@@ -1,32 +1,25 @@
# v0.6.5 (2026-04-28) # v0.7.2 (2026-05-11)
UI polish across the redesign: command-template editing now groups slots into four labelled fieldsets that mirror the notification-template page, modal/popup scrolling no longer drags the page underneath, and Telegram's Discover Chats keeps the existing list visible with a smooth shimmer instead of blanking it to "Loading…". ## 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
- **Command template slots grouped into 4 fieldsets:** the command-template configs page now mirrors the notification-template layout, splitting slots by name prefix into Command Responses, Error Messages (`rate_limited` / `no_results`), Command Descriptions (`desc_*`), and Usage Examples (`usage_*`). The language picker, reset-all button, and slot filter are hoisted above the groups so they apply across all fieldsets, and empty groups are hidden so providers without `usage_*` slots don't render an empty header ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c)) - Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
### Bug Fixes
- **Modal scroll chaining contained:** scrolling past the inner boundary of a modal or popup no longer scrolls the page underneath. `overscroll-behavior: contain` was added to every in-modal/popup scroll container — Modal body, `EntitySelect`, `MultiEntitySelect`, `IconPicker`, `IconGridSelect`, `SearchPalette`, and `TimezoneSelector` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
- **Smoother Telegram Discover Chats refresh:** Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. The initial-load state (`chatsLoading`) is now split from the refresh state (`chatsRefreshing`); rows are keyed by `chat.id` with flip+fade animations, the list dims with a sweeping shimmer while the Discover button shows a spinning icon and a "Discovering chats…" label. Honors `prefers-reduced-motion` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
## Development / Internal
### i18n
- Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
--- ---
<details> <details>
<summary>All Commits</summary> <summary>All Commits</summary>
| Hash | Message | Author | | Hash | Message | Author |
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------| |------|---------|--------|
| [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov | | [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
| [9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e) | fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh | 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> </details>
+16 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.6.1", "version": "0.7.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"version": "0.6.1", "version": "0.7.1",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -14,6 +14,7 @@
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8", "@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7", "@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5", "@fontsource/geist-sans": "^5.2.5",
@@ -607,6 +608,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true "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": { "node_modules/@fontsource/dm-sans": {
"version": "5.2.8", "version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true "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": { "@fontsource/dm-sans": {
"version": "5.2.8", "version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", "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", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.6.5", "version": "0.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -34,6 +34,7 @@
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8", "@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7", "@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5", "@fontsource/geist-sans": "^5.2.5",
+12 -6
View File
@@ -1,11 +1,17 @@
@import '@fontsource/geist-sans/300.css'; /* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
@import '@fontsource/geist-sans/400.css'; so RU and EN render in the same font instead of falling back to a
@import '@fontsource/geist-sans/500.css'; system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
@import '@fontsource/geist-sans/600.css'; (latin-only) imports — see --font-sans below for the family rename. */
@import '@fontsource/geist-sans/700.css'; @import '@fontsource-variable/geist';
@import '@fontsource/geist-mono/400.css'; @import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/500.css'; @import '@fontsource/geist-mono/500.css';
@import '@fontsource/geist-mono/600.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/300-italic.css';
@import '@fontsource/newsreader/400.css'; @import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.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); --shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */ /* 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-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif; --font-display: 'Newsreader', ui-serif, Georgia, serif;
+39 -19
View File
@@ -20,7 +20,10 @@
noneLabel = '—', noneLabel = '—',
disabled = false, disabled = false,
size = 'default', size = 'default',
open = $bindable(false),
showTrigger = true,
onselect, onselect,
onclose,
}: { }: {
items: EntityItem[]; items: EntityItem[];
value: string | number | null; value: string | number | null;
@@ -29,10 +32,12 @@
noneLabel?: string; noneLabel?: string;
disabled?: boolean; disabled?: boolean;
size?: 'sm' | 'default'; size?: 'sm' | 'default';
open?: boolean;
showTrigger?: boolean;
onselect?: (value: string | number | null) => void; onselect?: (value: string | number | null) => void;
onclose?: () => void;
} = $props(); } = $props();
let open = $state(false);
let query = $state(''); let query = $state('');
let highlightIdx = $state(0); let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>(); let inputEl = $state<HTMLInputElement | undefined>();
@@ -52,24 +57,37 @@
return [...result, ...matching]; 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() { function openPalette() {
if (disabled) return; if (disabled) return;
open = true; 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() { function closePalette() {
open = false; open = false;
query = ''; query = '';
onclose?.();
} }
function selectItem(item: EntityItem) { function selectItem(item: EntityItem) {
if (item.disabled) return; if (item.disabled) return;
value = item.value || null; value = item.value || null;
onselect?.(value); onselect?.(value);
closePalette(); open = false;
query = '';
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
@@ -106,21 +124,23 @@
}); });
</script> </script>
<!-- Trigger button --> <!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette} {#if showTrigger}
aria-expanded={open} <button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-haspopup="listbox" aria-expanded={open}
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};"> aria-haspopup="listbox"
{#if selected} style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected.icon} {#if selected}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span> {#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} {/if}
<span class="es-trigger-label">{selected.label}</span> <span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
{:else} </button>
<span class="es-trigger-label es-trigger-none">{placeholder}</span> {/if}
{/if}
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors --> <!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open} {#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>
+32
View File
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') }, { value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
]; ];
// --- Log level ---
export const logLevelItems = (): GridItem[] => [
{ value: 'DEBUG', icon: 'mdiBugOutline', label: 'DEBUG', desc: t('gridDesc.logLevelDebug') },
{ value: 'INFO', icon: 'mdiInformationOutline', label: 'INFO', desc: t('gridDesc.logLevelInfo') },
{ value: 'WARNING', icon: 'mdiAlertOutline', label: 'WARNING', desc: t('gridDesc.logLevelWarning') },
{ value: 'ERROR', icon: 'mdiAlertOctagonOutline', label: 'ERROR', desc: t('gridDesc.logLevelError') },
];
// --- Log format ---
export const logFormatItems = (): GridItem[] => [
{ value: 'text', icon: 'mdiFormatText', label: 'text', desc: t('gridDesc.logFormatText') },
{ value: 'json', icon: 'mdiCodeJson', label: 'json', desc: t('gridDesc.logFormatJson') },
];
// --- Response mode --- // --- Response mode ---
export const responseModeItems = (tFn: typeof t): GridItem[] => [ export const responseModeItems = (tFn: typeof t): GridItem[] => [
@@ -92,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') }, { 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_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: '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) --- // --- Sort filter (dashboard) ---
@@ -101,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') }, { 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) --- // --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [ export const chatActionItems = (): GridItem[] => [
+127 -4
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge", "name": "Notify Bridge",
"tagline": "Service notifications" "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": { "nav": {
"sectionOverview": "Overview", "sectionOverview": "Overview",
"sectionRouting": "Routing", "sectionRouting": "Routing",
@@ -87,6 +98,15 @@
"actionSuccess": "action run", "actionSuccess": "action run",
"actionPartial": "action partial", "actionPartial": "action partial",
"actionFailed": "action failed", "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...", "searchEvents": "Search events...",
"allEvents": "All Events", "allEvents": "All Events",
"filterAssetsAdded": "Assets Added", "filterAssetsAdded": "Assets Added",
@@ -97,6 +117,9 @@
"filterActionSuccess": "Action Success", "filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial", "filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed", "filterActionFailed": "Action Failed",
"filterCommandHandled": "Command Handled",
"filterCommandRateLimited": "Rate Limited",
"filterCommandFailed": "Command Failed",
"allProviders": "All Providers", "allProviders": "All Providers",
"newestFirst": "Newest first", "newestFirst": "Newest first",
"oldestFirst": "Oldest first", "oldestFirst": "Oldest first",
@@ -141,6 +164,23 @@
"newTracker": "New tracker", "newTracker": "New tracker",
"eventsTotal": "Events" "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": { "providers": {
"title": "Service", "title": "Service",
"titleEmphasis": "providers", "titleEmphasis": "providers",
@@ -192,7 +232,8 @@
"apiToken": "API Token", "apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.", "apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"webhookUrl": "Webhook URL", "webhookUrl": "Webhook URL",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).", "webhookUrlHint": "Set this as the Target URL in Gitea webhook settings. The full URL is shown when an external base URL is configured in Settings; otherwise it is relative to your bridge host.",
"webhookUrlCopyTitle": "Click to copy",
"nutHost": "NUT Server Host", "nutHost": "NUT Server Host",
"nutHostPlaceholder": "192.168.1.100 or ups.local", "nutHostPlaceholder": "192.168.1.100 or ups.local",
"nutPort": "NUT Server Port", "nutPort": "NUT Server Port",
@@ -312,6 +353,7 @@
"checkingLinks": "Checking links...", "checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.", "featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config", "openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace", "linkReplace": "Replace",
"linkReplacing": "Replacing...", "linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"", "linkReplaceFailed": "Failed to replace link for \"{name}\"",
@@ -419,7 +461,13 @@
"receiverUpdated": "Receiver updated", "receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?", "confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled", "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": { "users": {
"titleEmphasis": "& access", "titleEmphasis": "& access",
@@ -788,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.", "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", "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", "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": { "hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.", "periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -888,6 +970,7 @@
"titleEmphasis": "configs", "titleEmphasis": "configs",
"countLabel": "configs", "countLabel": "configs",
"title": "Command Configs", "title": "Command Configs",
"noCommandsForProvider": "No commands available for this provider type.",
"description": "Define command settings for Telegram bot interactions", "description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config", "newConfig": "New Config",
"name": "Name", "name": "Name",
@@ -1024,6 +1107,8 @@
"edit": "Edit", "edit": "Edit",
"description": "Description", "description": "Description",
"close": "Close", "close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm", "confirm": "Confirm",
"cannotDelete": "Cannot delete", "cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:", "blockedByIntro": "Referenced by:",
@@ -1131,6 +1216,12 @@
"memorySourceNative": "Use Immich native memories API", "memorySourceNative": "Use Immich native memories API",
"localeEn": "English interface", "localeEn": "English interface",
"localeRu": "Russian interface", "localeRu": "Russian interface",
"logLevelDebug": "Verbose — show every step",
"logLevelInfo": "Default — high-level events",
"logLevelWarning": "Warnings and errors only",
"logLevelError": "Errors only — quietest",
"logFormatText": "Human-readable plain text",
"logFormatJson": "One JSON object per line",
"modeMedia": "Send actual photo/video files", "modeMedia": "Send actual photo/video files",
"modeText": "Send file names and links only", "modeText": "Send file names and links only",
"allEvents": "Show all event types", "allEvents": "Show all event types",
@@ -1142,6 +1233,14 @@
"actionSuccess": "Scheduled action completed", "actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded", "actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed", "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", "newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top", "oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown", "chatActionNone": "No indicator shown",
@@ -1324,6 +1423,30 @@
"applyLater": "Apply later", "applyLater": "Apply later",
"restartNow": "Restart now", "restartNow": "Restart now",
"restartingTitle": "Restarting backend…", "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"
} }
} }
+127 -4
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge", "name": "Notify Bridge",
"tagline": "Уведомления о сервисах" "tagline": "Уведомления о сервисах"
}, },
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": { "nav": {
"sectionOverview": "Обзор", "sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация", "sectionRouting": "Маршрутизация",
@@ -87,6 +98,15 @@
"actionSuccess": "действие выполнено", "actionSuccess": "действие выполнено",
"actionPartial": "действие частично", "actionPartial": "действие частично",
"actionFailed": "действие провалено", "actionFailed": "действие провалено",
"commandHandled": "команда обработана",
"commandRateLimited": "ограничение частоты",
"commandFailed": "команда упала",
"autoRefreshTitle": "Интервал авто-обновления списка событий",
"refreshOff": "Выкл",
"refresh10s": "10с",
"refresh30s": "30с",
"refresh60s": "1м",
"refresh5m": "5м",
"searchEvents": "Поиск событий...", "searchEvents": "Поиск событий...",
"allEvents": "Все события", "allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов", "filterAssetsAdded": "Добавление файлов",
@@ -97,6 +117,9 @@
"filterActionSuccess": "Действие выполнено", "filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично", "filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено", "filterActionFailed": "Действие провалено",
"filterCommandHandled": "Команда обработана",
"filterCommandRateLimited": "Ограничение частоты",
"filterCommandFailed": "Команда упала",
"allProviders": "Все провайдеры", "allProviders": "Все провайдеры",
"newestFirst": "Сначала новые", "newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые", "oldestFirst": "Сначала старые",
@@ -141,6 +164,23 @@
"newTracker": "Новый трекер", "newTracker": "Новый трекер",
"eventsTotal": "Событий" "eventsTotal": "Событий"
}, },
"events": {
"detailTitle": "Детали события",
"bot": "Бот",
"chat": "Чат",
"issuer": "Отправитель",
"commandTracker": "Командный трекер",
"tracker": "Трекер",
"action": "Действие",
"provider": "Провайдер",
"assetsCount": "Файлов",
"openProvider": "Открыть провайдера",
"openBot": "Открыть бота",
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
},
"providers": { "providers": {
"title": "Сервисные", "title": "Сервисные",
"titleEmphasis": "провайдеры", "titleEmphasis": "провайдеры",
@@ -192,7 +232,8 @@
"apiToken": "API токен", "apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"webhookUrl": "URL вебхука", "webhookUrl": "URL вебхука",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).", "webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
"nutHost": "Хост NUT-сервера", "nutHost": "Хост NUT-сервера",
"nutHostPlaceholder": "192.168.1.100 или ups.local", "nutHostPlaceholder": "192.168.1.100 или ups.local",
"nutPort": "Порт NUT-сервера", "nutPort": "Порт NUT-сервера",
@@ -312,6 +353,7 @@
"checkingLinks": "Проверка ссылок...", "checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.", "featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания", "openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать", "linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...", "linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»", "linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -419,7 +461,13 @@
"receiverUpdated": "Получатель обновлён", "receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?", "confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён", "receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён" "receiverDisabled": "Получатель отключён",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
"target": "получатель",
"targetsLower": "получателей",
"openBot": "Открыть бота"
}, },
"users": { "users": {
"titleEmphasis": "и доступ", "titleEmphasis": "и доступ",
@@ -788,7 +836,41 @@
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.", "logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям", "logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG", "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": { "hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.", "periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -888,6 +970,7 @@
"titleEmphasis": "конфигурации", "titleEmphasis": "конфигурации",
"countLabel": "конфигураций", "countLabel": "конфигураций",
"title": "Конфигурации команд", "title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами", "description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация", "newConfig": "Новая конфигурация",
"name": "Название", "name": "Название",
@@ -1024,6 +1107,8 @@
"edit": "Редактировать", "edit": "Редактировать",
"description": "Описание", "description": "Описание",
"close": "Закрыть", "close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить", "cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:", "blockedByIntro": "На объект ссылаются:",
@@ -1131,6 +1216,12 @@
"memorySourceNative": "Использовать API воспоминаний Immich", "memorySourceNative": "Использовать API воспоминаний Immich",
"localeEn": "Английский интерфейс", "localeEn": "Английский интерфейс",
"localeRu": "Русский интерфейс", "localeRu": "Русский интерфейс",
"logLevelDebug": "Подробный — каждый шаг",
"logLevelInfo": "По умолчанию — ключевые события",
"logLevelWarning": "Только предупреждения и ошибки",
"logLevelError": "Только ошибки — самый тихий",
"logFormatText": "Читаемый человеком текст",
"logFormatJson": "Один JSON-объект на строку",
"modeMedia": "Отправка файлов фото/видео", "modeMedia": "Отправка файлов фото/видео",
"modeText": "Только имена файлов и ссылки", "modeText": "Только имена файлов и ссылки",
"allEvents": "Показать все типы событий", "allEvents": "Показать все типы событий",
@@ -1142,6 +1233,14 @@
"actionSuccess": "Запланированное действие выполнено", "actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично", "actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено", "actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события", "newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события", "oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается", "chatActionNone": "Индикатор не показывается",
@@ -1324,6 +1423,30 @@
"applyLater": "Применить позже", "applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас", "restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…", "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": "Сначала проверьте файл, чтобы включить импорт"
} }
} }
+28
View File
@@ -112,6 +112,34 @@ export const capabilitiesCache = (() => {
}; };
})(); })();
/** Configured external base URL — used to render absolute webhook URLs.
* Available to all authenticated users. Empty string when unset. */
export const externalUrlCache = (() => {
let data = $state<string>('');
let fetchedAt = $state(0);
let inflight: Promise<string> | null = null;
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
async fetch(force = false): Promise<string> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await api<{ external_url: string }>('/settings/external-url');
data = (res?.external_url || '').replace(/\/+$/, '');
fetchedAt = Date.now();
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */ /** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => { export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']); let data = $state<string[]>(['en', 'ru']);
+7
View File
@@ -217,9 +217,16 @@ export interface EventLog {
event_type: string; event_type: string;
collection_id: string; collection_id: string;
collection_name: string; collection_name: string;
tracker_id?: number | null;
tracker_name: string; tracker_name: string;
provider_name: string; provider_name: string;
provider_id: number | null; 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; assets_count: number;
details: Record<string, any>; details: Record<string, any>;
created_at: string; created_at: string;
+170 -18
View File
@@ -16,12 +16,13 @@
import EventChart from '$lib/components/EventChart.svelte'; import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.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 { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers'; import { getDescriptor } from '$lib/providers';
import type { DashboardStatus } from '$lib/types'; import type { DashboardStatus, EventLog } from '$lib/types';
const SECTIONS_KEY = 'dashboard_section_state'; const SECTIONS_KEY = 'dashboard_section_state';
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires'; type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
@@ -75,10 +76,57 @@
return stored ? parseInt(stored, 10) || 10 : 10; 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 eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0); let eventsOffset = $state(0);
let eventsLoading = $state(false); let eventsLoading = $state(false);
let confirmClearEvents = $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() { async function clearEvents() {
try { try {
@@ -119,22 +167,53 @@
return params; return params;
} }
async function loadEvents() { /** Reload the events panel.
eventsLoading = true; *
* ``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 { try {
const params = buildFilterParams(); const params = buildFilterParams();
params.set('sort', filterSort); params.set('sort', filterSort);
params.set('limit', String(eventsLimit)); params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset)); params.set('offset', String(eventsOffset));
const qs = params.toString(); 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) { } catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error'); error = err instanceof Error ? err.message : t('common.error');
} finally { } 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() { async function loadChart() {
try { try {
const params = buildFilterParams(); 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 const filteredProviderCount = $derived(globalProviderFilter.providerType
? providers.filter(p => p.type === globalProviderFilter.providerType).length ? providers.filter(p => p.type === globalProviderFilter.providerType).length
: displayProviders); : displayProviders);
@@ -360,6 +449,9 @@
action_success: 'dashboard.actionSuccess', action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial', action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed', action_failed: 'dashboard.actionFailed',
command_handled: 'dashboard.commandHandled',
command_rate_limited: 'dashboard.commandRateLimited',
command_failed: 'dashboard.commandFailed',
}; };
const eventIcons: Record<string, string> = { const eventIcons: Record<string, string> = {
@@ -367,6 +459,7 @@
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant', collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock', scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle', 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 // Aurora gradient palette per event type — used for the avatar tile
@@ -380,6 +473,9 @@
action_success: ['var(--color-mint)', 'var(--color-primary)'], action_success: ['var(--color-mint)', 'var(--color-primary)'],
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'], action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
action_failed: ['var(--color-coral)', '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 = [ const STAT_ACCENTS = [
@@ -554,6 +650,11 @@
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div> <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} {#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-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> </div>
{#snippet paginator()} {#snippet paginator()}
@@ -588,17 +689,25 @@
</div> </div>
{/snippet} {/snippet}
{#if eventsLoading} {#if status.recent_events.length === 0}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div> {#if eventsLoading}
{:else if status.recent_events.length === 0} <div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
<div class="empty-state"> {:else}
<MdiIcon name="mdiCalendarBlank" size={36} /> <div class="empty-state">
<p>{t('dashboard.noEvents')}</p> <MdiIcon name="mdiCalendarBlank" size={36} />
</div> <p>{t('dashboard.noEvents')}</p>
</div>
{/if}
{:else} {:else}
<div class="signal-list stagger-children"> <div class="signal-list"
{#each status.recent_events as event, i} class:stagger-children={!eventsAnimated}
<div class="signal-row" style="animation-delay: {i * 60}ms;"> 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" <div class="signal-avatar"
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};"> 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} /> <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> <b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if} {/if}
</div> </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"> <div class="signal-trail">
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span> <span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
{#if event.provider_name} {#if event.provider_name}
@@ -629,7 +760,7 @@
<b>{timeShort(event.created_at)}</b> <b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small> <small>{timeAgo(event.created_at)}</small>
</div> </div>
</div> </button>
{/each} {/each}
</div> </div>
@@ -790,6 +921,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')} <ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} /> onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style> <style>
/* ============================================================ /* ============================================================
HERO HERO
@@ -1118,6 +1251,11 @@
SIGNAL STREAM — events with routing trail SIGNAL STREAM — events with routing trail
============================================================ */ ============================================================ */
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; } .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 { .signal-row {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto; grid-template-columns: 40px 1fr auto;
@@ -1129,6 +1267,20 @@
} }
.signal-row + .signal-row { border-top: 1px solid var(--color-border); } .signal-row + .signal-row { border-top: 1px solid var(--color-border); }
.signal-row:hover { background: var(--color-glass-strong); } .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 { .signal-avatar {
width: 40px; height: 40px; width: 40px; height: 40px;
border-radius: 12px; border-radius: 12px;
+16 -2
View File
@@ -40,7 +40,19 @@
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '', schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false, enabled: false,
}); });
let nameManuallyEdited = $state(false);
let error = $state(''); let error = $state('');
function actionTypeLabel(at: string): string {
return at.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find((p: any) => p.id === form.provider_id);
const at = actionTypeLabel(form.action_type || '');
form.name = provider ? `${provider.name} ${at}`.trim() : at || 'Action';
}
});
let loadError = $state(''); let loadError = $state('');
let submitting = $state(false); let submitting = $state(false);
let loaded = $state(false); let loaded = $state(false);
@@ -98,6 +110,7 @@
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '', config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false, enabled: false,
}; };
nameManuallyEdited = false;
editing = null; showForm = true; editing = null; showForm = true;
} }
@@ -109,6 +122,7 @@
schedule_interval: action.schedule_interval, schedule_interval: action.schedule_interval,
schedule_cron: action.schedule_cron, enabled: action.enabled, schedule_cron: action.schedule_cron, enabled: action.enabled,
}; };
nameManuallyEdited = true;
editing = action.id; showForm = true; editing = action.id; showForm = true;
} }
@@ -185,7 +199,7 @@
title={t('actions.title')} title={t('actions.title')}
emphasis={t('actions.titleEmphasis')} emphasis={t('actions.titleEmphasis')}
description={t('actions.description')} description={t('actions.description')}
crumb="Routing · Automation" crumb={t('crumbs.routingAutomation')}
count={actions.length} count={actions.length}
countLabel={t('actions.countLabel')} countLabel={t('actions.countLabel')}
pills={headerPills} pills={headerPills}
@@ -245,7 +259,7 @@
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label> <label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="act-name" bind:value={form.name} required <input id="act-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
+13 -4
View File
@@ -30,8 +30,16 @@
smtp_username: '', smtp_password: '', smtp_use_tls: true, smtp_username: '', smtp_password: '', smtp_use_tls: true,
}); });
let emailForm = $state(defaultEmailForm()); let emailForm = $state(defaultEmailForm());
let nameManuallyEdited = $state(false);
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; } const DEFAULT_BOT_NAME = 'Email Bot';
$effect(() => {
if (showEmailForm && !nameManuallyEdited && !editingEmail) {
emailForm.name = DEFAULT_BOT_NAME;
}
});
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) { function editEmailBot(bot: EmailBot) {
emailForm = { emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email, name: bot.name, icon: bot.icon || '', email: bot.email,
@@ -39,6 +47,7 @@
smtp_username: bot.smtp_username, smtp_password: '', smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls, smtp_use_tls: bot.smtp_use_tls,
}; };
nameManuallyEdited = true;
editingEmail = bot.id; showEmailForm = true; editingEmail = bot.id; showEmailForm = true;
} }
@@ -54,7 +63,7 @@
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) }); await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated')); snackSuccess(t('snack.emailBotCreated'));
} }
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload(); emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; } finally { emailSubmitting = false; }
} }
@@ -90,7 +99,7 @@
title={t('emailBot.title')} title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')} emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')} description={t('emailBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={emailBots.length} count={emailBots.length}
countLabel={t('emailBot.countLabel')} countLabel={t('emailBot.countLabel')}
> >
@@ -107,7 +116,7 @@
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label> <label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} /> <IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')} <input id="ebot-name" bind:value={emailForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
+13 -4
View File
@@ -29,14 +29,23 @@
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '', name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
}); });
let matrixForm = $state(defaultMatrixForm()); let matrixForm = $state(defaultMatrixForm());
let nameManuallyEdited = $state(false);
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; } const DEFAULT_BOT_NAME = 'Matrix Bot';
$effect(() => {
if (showMatrixForm && !nameManuallyEdited && !editingMatrix) {
matrixForm.name = DEFAULT_BOT_NAME;
}
});
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) { function editMatrixBot(bot: MatrixBot) {
matrixForm = { matrixForm = {
name: bot.name, icon: bot.icon || '', name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '', homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '', display_name: bot.display_name || '',
}; };
nameManuallyEdited = true;
editingMatrix = bot.id; showMatrixForm = true; editingMatrix = bot.id; showMatrixForm = true;
} }
@@ -52,7 +61,7 @@
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) }); await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated')); snackSuccess(t('snack.matrixBotCreated'));
} }
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload(); matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { matrixSubmitting = false; } finally { matrixSubmitting = false; }
} }
@@ -88,7 +97,7 @@
title={t('matrixBot.title')} title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')} emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')} description={t('matrixBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={matrixBots.length} count={matrixBots.length}
countLabel={t('matrixBot.countLabel')} countLabel={t('matrixBot.countLabel')}
> >
@@ -105,7 +114,7 @@
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label> <label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} /> <IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')} <input id="mbot-name" bind:value={matrixForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('matrixBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
+13 -5
View File
@@ -29,10 +29,18 @@
let showForm = $state(false); let showForm = $state(false);
let editing = $state<number | null>(null); let editing = $state<number | null>(null);
let form = $state({ name: '', icon: '', token: '' }); let form = $state({ name: '', icon: '', token: '' });
let nameManuallyEdited = $state(false);
let error = $state(''); let error = $state('');
let submitting = $state(false); let submitting = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null); let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
const DEFAULT_BOT_NAME = 'Telegram Bot';
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = DEFAULT_BOT_NAME;
}
});
// Per-bot expandable sections // Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({}); let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({}); let chatsLoading = $state<Record<number, boolean>>({});
@@ -52,8 +60,8 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({}); let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({}); let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; } function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; } function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
async function saveBot(e: SubmitEvent) { async function saveBot(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true; e.preventDefault(); error = ''; submitting = true;
@@ -65,7 +73,7 @@
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) }); await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.botRegistered')); snackSuccess(t('snack.botRegistered'));
} }
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload(); form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; } finally { submitting = false; }
} }
@@ -295,7 +303,7 @@
title={t('telegramBot.title')} title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')} emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')} description={t('telegramBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={bots.length} count={bots.length}
countLabel={t('telegramBot.countLabel')} countLabel={t('telegramBot.countLabel')}
> >
@@ -312,7 +320,7 @@
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label> <label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')} <input id="bot-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('telegramBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
@@ -21,6 +21,7 @@
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { getDescriptor } from '$lib/providers';
import type { CommandConfig } from '$lib/types'; import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string { function templateName(id: number | null): string {
@@ -69,6 +70,14 @@
command_template_config_id: null as number | null, command_template_config_id: null as number | null,
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Commands` : 'Commands';
}
});
let allCapabilities = $derived(capabilitiesCache.items); let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>( let providerCommands = $derived<{key: string, icon: string}[]>(
@@ -107,6 +116,7 @@
// Auto-select first matching template for the chosen provider_type // Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type); const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id; if (match) form.command_template_config_id = match.id;
nameManuallyEdited = false;
editing = null; editing = null;
showForm = true; showForm = true;
} }
@@ -142,6 +152,7 @@
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 }, rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
command_template_config_id: cfg.command_template_config_id ?? null, command_template_config_id: cfg.command_template_config_id ?? null,
}; };
nameManuallyEdited = true;
editing = cfg.id; editing = cfg.id;
showForm = true; showForm = true;
} }
@@ -165,7 +176,7 @@
await api('/command-configs', { method: 'POST', body }); await api('/command-configs', { method: 'POST', body });
snackSuccess(t('snack.commandConfigSaved')); snackSuccess(t('snack.commandConfigSaved'));
} }
form = defaultForm(); showForm = false; editing = null; await load(); form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; } finally { submitting = false; }
} }
@@ -194,7 +205,7 @@
title={t('commandConfig.title')} title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')} emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')} description={t('commandConfig.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={configs.length} count={configs.length}
countLabel={t('commandConfig.countLabel')} countLabel={t('commandConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -214,7 +225,7 @@
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label> <label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')} <input id="cfg-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
@@ -26,6 +26,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
interface CmdTemplateConfig { interface CmdTemplateConfig {
id: number; id: number;
@@ -120,6 +121,14 @@
slots: {} as Record<string, Record<string, string>>, slots: {} as Record<string, Record<string, string>>,
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Command Templates` : 'Command Templates';
}
});
// Provider capabilities // Provider capabilities
let allCapabilities = $state<Record<string, any>>({}); let allCapabilities = $state<Record<string, any>>({});
@@ -257,6 +266,7 @@
form = defaultForm(); form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0); const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0]; if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
nameManuallyEdited = false;
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = primaryLocale; activeLocale = primaryLocale;
@@ -280,6 +290,7 @@
icon: c.icon || '', icon: c.icon || '',
slots: slotsCopy, slots: slotsCopy,
}; };
nameManuallyEdited = true;
editing = c.id; editing = c.id;
showForm = true; showForm = true;
activeLocale = primaryLocale; activeLocale = primaryLocale;
@@ -411,7 +422,7 @@
title={t('cmdTemplateConfig.title')} title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')} emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')} description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={configs.length} count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')} countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -432,7 +443,7 @@
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label> <label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')} <input id="ct-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
@@ -61,6 +61,14 @@
enabled: true, enabled: true,
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find(p => p.id === form.provider_id);
form.name = provider ? `${provider.name} Commands` : 'Commands';
}
});
// Filter command configs by selected provider's type // Filter command configs by selected provider's type
let filteredConfigs = $derived.by(() => { let filteredConfigs = $derived.by(() => {
@@ -110,6 +118,7 @@
const firstCfg = commandConfigs.find(c => c.provider_type === ptype); const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id; if (firstCfg) form.command_config_id = firstCfg.id;
} }
nameManuallyEdited = false;
editing = null; editing = null;
showForm = true; showForm = true;
} }
@@ -141,6 +150,7 @@
command_config_id: trk.command_config_id, command_config_id: trk.command_config_id,
enabled: trk.enabled, enabled: trk.enabled,
}; };
nameManuallyEdited = true;
editing = trk.id; editing = trk.id;
showForm = true; showForm = true;
} }
@@ -156,7 +166,7 @@
await api('/command-trackers', { method: 'POST', body }); await api('/command-trackers', { method: 'POST', body });
snackSuccess(t('snack.commandTrackerCreated')); snackSuccess(t('snack.commandTrackerCreated'));
} }
form = defaultForm(); showForm = false; editing = null; await load(); form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; } finally { submitting = false; }
} }
@@ -268,7 +278,7 @@
title={t('commandTracker.title')} title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')} emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')} description={t('commandTracker.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={trackers.length} count={trackers.length}
countLabel={t('dashboard.trackersShort')} countLabel={t('dashboard.trackersShort')}
pills={headerPills} pills={headerPills}
@@ -288,7 +298,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label> <label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')} <input id="trk-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandTracker.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
@@ -70,11 +70,19 @@
filters: {} as Record<string, any>, filters: {} as Record<string, any>,
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let selectedProviderType = $derived( let selectedProviderType = $derived(
providers.find(p => p.id === form.provider_id)?.type || '' providers.find(p => p.id === form.provider_id)?.type || ''
); );
let error = $state(''); let error = $state('');
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const provider = providers.find(p => p.id === form.provider_id);
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
}
});
// Linked targets management // Linked targets management
let expandedTracker = $state<number | null>(null); let expandedTracker = $state<number | null>(null);
let addingTarget = $state<Record<number, boolean>>({}); let addingTarget = $state<Record<number, boolean>>({});
@@ -210,6 +218,7 @@
form = defaultForm(); form = defaultForm();
// Auto-select first provider if any // Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id; if (providers.length > 0) form.provider_id = providers[0].id;
nameManuallyEdited = false;
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = []; editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
} }
@@ -224,6 +233,7 @@
filters: trk.filters || {}, filters: trk.filters || {},
}; };
previousCollectionIds = [...(trk.collection_ids || [])]; previousCollectionIds = [...(trk.collection_ids || [])];
nameManuallyEdited = true;
editing = trk.id; showForm = true; editing = trk.id; showForm = true;
if (form.provider_id) { if (form.provider_id) {
await Promise.all([loadCollections(), loadUsers()]); await Promise.all([loadCollections(), loadUsers()]);
@@ -458,7 +468,7 @@
title={t('notificationTracker.title')} title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')} emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')} description={t('notificationTracker.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={notificationTrackers.length} count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')} countLabel={t('dashboard.trackersShort')}
pills={headerPills} pills={headerPills}
@@ -491,6 +501,7 @@
onsave={save} onsave={save}
ontoggleCollection={toggleCollection} ontoggleCollection={toggleCollection}
{formatDate} {formatDate}
onnameinput={() => nameManuallyEdited = true}
/> />
{/if} {/if}
@@ -35,6 +35,7 @@
onsave: (e: SubmitEvent) => void; onsave: (e: SubmitEvent) => void;
ontoggleCollection?: (collectionId: string) => void; ontoggleCollection?: (collectionId: string) => void;
formatDate?: (dateStr: string) => string; formatDate?: (dateStr: string) => string;
onnameinput?: () => void;
} }
let { let {
@@ -53,6 +54,7 @@
onsave, onsave,
ontoggleCollection, ontoggleCollection,
formatDate, formatDate,
onnameinput,
}: Props = $props(); }: Props = $props();
let descriptor = $derived(getDescriptor(providerType)); let descriptor = $derived(getDescriptor(providerType));
@@ -95,7 +97,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label> <label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="trk-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
<div> <div>
@@ -225,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span> <span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs"> <div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p> <p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href={form.default_tracking_config_id <div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
? `/tracking-configs?edit=${form.default_tracking_config_id}` <a href={form.default_tracking_config_id
: '/tracking-configs'} ? `/tracking-configs?edit=${form.default_tracking_config_id}`
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1"> : '/tracking-configs'}
<MdiIcon name="mdiArrowRight" size={12} /> class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
{t('notificationTracker.openTrackingConfig')} <MdiIcon name="mdiArrowRight" size={12} />
</a> {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>
</div> </div>
{/if} {/if}
+43 -5
View File
@@ -3,7 +3,7 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte'; import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
@@ -21,7 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte'; import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers'; import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
@@ -45,6 +45,30 @@
let confirmDelete = $state<ServiceProvider | null>(null); let confirmDelete = $state<ServiceProvider | null>(null);
let descriptor = $derived(getDescriptor(form.type)); let descriptor = $derived(getDescriptor(form.type));
let externalUrl = $derived(externalUrlCache.value);
function buildWebhookUrl(pattern: string, token: string): string {
const path = pattern.replace('{token}', token ?? '');
return externalUrl ? `${externalUrl}${path}` : path;
}
function copyWebhookUrl(e: Event, url: string) {
e.preventDefault();
e.stopPropagation();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
snackInfo(`${t('snack.copied')}: ${url}`);
}
// Auto-update name when provider type changes (unless user manually edited) // Auto-update name when provider type changes (unless user manually edited)
$effect(() => { $effect(() => {
@@ -76,6 +100,7 @@
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); }, onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
}); });
load(); load();
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
}); });
onDestroy(() => topbarAction.clear()); onDestroy(() => topbarAction.clear());
async function load() { async function load() {
@@ -173,7 +198,7 @@
title={t('providers.title')} title={t('providers.title')}
emphasis={t('providers.titleEmphasis')} emphasis={t('providers.titleEmphasis')}
description={t('providers.description')} description={t('providers.description')}
crumb="Service · Connections" crumb={t('crumbs.serviceConnections')}
count={providers.length} count={providers.length}
countLabel={t('dashboard.providersShort')} countLabel={t('dashboard.providersShort')}
pills={headerPills} pills={headerPills}
@@ -246,9 +271,15 @@
</div> </div>
{/each} {/each}
{#if descriptor?.webhookUrlPattern && editing} {#if descriptor?.webhookUrlPattern && editing}
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
<div class="bg-[var(--color-muted)] rounded-md p-3"> <div class="bg-[var(--color-muted)] rounded-md p-3">
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div> <div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code> <button type="button"
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
<code class="bg-transparent">{editingWebhookUrl}</code>
</button>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p> <p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div> </div>
{/if} {/if}
@@ -295,7 +326,14 @@
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p> <p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if} {/if}
{#if provDesc?.webhookUrlPattern} {#if provDesc?.webhookUrlPattern}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p> {@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if} {/if}
</div> </div>
</div> </div>
+150 -195
View File
@@ -2,17 +2,18 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { t } from '$lib/i18n'; 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 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 ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; 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 { interface CacheBucketStats {
count: number; count: number;
@@ -25,12 +26,19 @@
asset: CacheBucketStats; asset: CacheBucketStats;
} }
let loaded = $state(false); interface Settings {
let saving = $state(false); external_url: string;
let clearingCache = $state(false); telegram_webhook_secret: string;
let confirmClearCache = $state(false); telegram_cache_ttl_hours: string;
let error = $state(''); telegram_asset_cache_max_entries: string;
let settings = $state({ supported_locales: string;
timezone: string;
log_level: string;
log_format: string;
log_levels: string;
}
const EMPTY: Settings = {
external_url: '', external_url: '',
telegram_webhook_secret: '', telegram_webhook_secret: '',
telegram_cache_ttl_hours: '720', telegram_cache_ttl_hours: '720',
@@ -40,10 +48,33 @@
log_level: 'INFO', log_level: 'INFO',
log_format: 'text', log_format: 'text',
log_levels: '', 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); 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 { try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats'); cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; } } catch { cacheStats = null; }
@@ -51,211 +82,135 @@
onMount(async () => { onMount(async () => {
try { try {
settings = await api('/settings'); const fetched = await api<Settings>('/settings');
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats(); await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: unknown) {
finally { loaded = true; } const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
snackError(msg);
} finally {
loaded = true;
}
}); });
function formatBytes(bytes: number): string { // --- Actions ------------------------------------------------------------
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 formatTs(iso: string | null): string { async function save(): Promise<void> {
if (!iso) return '—'; saving = true;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'); error = '';
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
try { 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')); snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: unknown) {
saving = false; 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; confirmClearCache = false;
clearingCache = true; clearingCache = true;
try { try {
await api('/settings/telegram-cache/clear', { method: 'POST' }); await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone')); snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats(); await loadCacheStats();
} catch (err: any) { snackError(err.message); } } catch (err: unknown) {
clearingCache = false; 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> </script>
<PageHeader <SettingsHero {settings} />
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
{#if !loaded} {#if !loaded}
<Loading /> <Loading />
{:else} {:else}
<ErrorBanner message={error} /> <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 --> <div class="settings-page stagger-children">
<Card> <IdentityCassette
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2"> bind:externalUrl={settings.external_url}
<MdiIcon name="mdiSend" size={18} /> bind:timezone={settings.timezone}
{t('settings.telegram')} bind:supportedLocales={settings.supported_locales}
</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>
<!-- Locales section --> <div class="telegram-deck">
<Card> <TelegramCassette
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2"> bind:webhookSecret={settings.telegram_webhook_secret}
<MdiIcon name="mdiTranslate" size={18} /> bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
{t('settings.locales')} bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
</h3> />
<div class="space-y-3"> <CacheLedger
<div> stats={cacheStats}
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label> clearing={clearingCache}
<LocaleSelector bind:value={settings.supported_locales} /> maxEntries={cacheMaxEntriesNum}
</div> onRefresh={loadCacheStats}
</div> onClear={() => (confirmClearCache = true)}
</Card> />
</div>
<!-- Logging section --> <LoggingCassette
<Card> bind:logLevel={settings.log_level}
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2"> bind:logFormat={settings.log_format}
<MdiIcon name="mdiTextBoxOutline" size={18} /> bind:logLevels={settings.log_levels}
{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>
<select bind:value={settings.log_level}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<select bind:value={settings.log_format}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="text">text</option>
<option value="json">json</option>
</select>
</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>
</div> </div>
<ConfirmModal open={confirmClearCache} <SaveBar
title={t('settings.clearCacheConfirmTitle')} {dirty}
message={t('settings.clearCacheConfirm')} {saving}
confirmLabel={t('settings.clearCacheConfirmBtn')} changedCount={dirtyKeys.length}
confirmIcon="mdiDeleteSweep" onSave={save}
onconfirm={clearTelegramCache} onDiscard={discard}
oncancel={() => confirmClearCache = false} /> />
{/if} {/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 { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api'; import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n'; 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 Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -11,32 +9,55 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
// --- Export state --- import BackupHero from './BackupHero.svelte';
let exportSecrets = $state('exclude'); import PendingStrip from './PendingStrip.svelte';
let exporting = $state(false); import ExportPanel from './ExportPanel.svelte';
import ImportPanel from './ImportPanel.svelte';
import ScheduleCassette from './ScheduleCassette.svelte';
import BackupLedger from './BackupLedger.svelte';
const categories = [ type SecretsMode = 'exclude' | 'masked' | 'include';
{ key: 'providers', label: 'backup.catProviders' }, type ConflictMode = 'skip' | 'rename' | 'overwrite';
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
{ key: 'matrix_bots', label: 'backup.catMatrixBots' }, interface BackupFile {
{ key: 'email_bots', label: 'backup.catEmailBots' }, filename: string;
{ key: 'targets', label: 'backup.catTargets' }, size: number;
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' }, created_at?: string | null;
{ key: 'template_configs', label: 'backup.catTemplateConfigs' }, }
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' }, interface ScheduledSettings {
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' }, backup_scheduled_enabled: string;
{ key: 'command_trackers', label: 'backup.catCommandTrackers' }, backup_scheduled_interval_hours: string;
{ key: 'actions', label: 'backup.catActions' }, backup_secrets_mode: string;
{ key: 'app_settings', label: 'backup.catAppSettings' }, 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>>( let selectedCategories = $state<Record<string, boolean>>(
Object.fromEntries(categories.map(c => [c.key, true])) Object.fromEntries(allCategories.map(k => [k, true]))
); );
// --- Import state --- // --- Import state ---
let importFile: File | null = $state(null); let importFile: File | null = $state(null);
let importConflict = $state('skip'); let importConflict = $state<ConflictMode>('skip');
let importing = $state(false); let importing = $state(false);
let validating = $state(false); let validating = $state(false);
let validationResult: any = $state(null); let validationResult: any = $state(null);
@@ -47,7 +68,7 @@
// --- Scheduled backup state --- // --- Scheduled backup state ---
let loaded = $state(false); let loaded = $state(false);
let error = $state(''); let error = $state('');
let scheduledSettings = $state({ let scheduledSettings = $state<ScheduledSettings>({
backup_scheduled_enabled: 'false', backup_scheduled_enabled: 'false',
backup_scheduled_interval_hours: '24', backup_scheduled_interval_hours: '24',
backup_secrets_mode: 'exclude', backup_secrets_mode: 'exclude',
@@ -56,22 +77,22 @@
let savingSchedule = $state(false); let savingSchedule = $state(false);
// --- Backup files --- // --- Backup files ---
let backupFiles = $state<any[]>([]); let backupFiles = $state<BackupFile[]>([]);
let loadingFiles = $state(false); let loadingFiles = $state(false);
let confirmDeleteFile = $state(''); let confirmDeleteFile = $state('');
let creatingBackup = $state(false); let creatingBackup = $state(false);
// --- Pending restore state --- // --- 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 postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false); let restartingOverlay = $state(false);
onMount(async () => { onMount(async () => {
try { try {
const [settings, files, p] = await Promise.all([ const [settings, files, p] = await Promise.all([
api('/backup/scheduled'), api<ScheduledSettings>('/backup/scheduled'),
api('/backup/files'), api<BackupFile[]>('/backup/files'),
api('/backup/pending-restore'), api<PendingState>('/backup/pending-restore'),
]); ]);
scheduledSettings = settings; scheduledSettings = settings;
backupFiles = files; backupFiles = files;
@@ -84,7 +105,7 @@
} }
}); });
async function cancelPending() { async function cancelPending(): Promise<void> {
try { try {
await api('/backup/pending-restore', { method: 'DELETE' }); await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled')); snackSuccess(t('backup.pendingCancelled'));
@@ -92,14 +113,13 @@
} catch (err: any) { snackError(err.message); } } catch (err: any) { snackError(err.message); }
} }
async function applyAndRestart() { async function applyAndRestart(): Promise<void> {
try { try {
await api('/backup/apply-restart', { method: 'POST' }); await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true; restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now(); const startedAt = Date.now();
let attempts = 0; let attempts = 0;
const poll = async () => { const poll = async (): Promise<void> => {
attempts += 1; attempts += 1;
try { try {
const res = await fetch('/api/health'); const res = await fetch('/api/health');
@@ -117,7 +137,7 @@
} }
} }
async function createManualBackup() { async function createManualBackup(): Promise<void> {
creatingBackup = true; creatingBackup = true;
try { try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude'; const mode = scheduledSettings.backup_secrets_mode || 'exclude';
@@ -132,7 +152,7 @@
} }
// --- Export --- // --- Export ---
async function doExport() { async function doExport(): Promise<void> {
if (exportSecrets === 'include') { if (exportSecrets === 'include') {
confirmExportOpen = true; confirmExportOpen = true;
return; return;
@@ -140,7 +160,7 @@
await performExport(); await performExport();
} }
async function performExport() { async function performExport(): Promise<void> {
confirmExportOpen = false; confirmExportOpen = false;
exporting = true; exporting = true;
try { try {
@@ -165,8 +185,14 @@
} }
} }
// --- Validate --- // --- Validate / Import ---
async function validateFile() { function handleFileSelect(file: File | null): void {
importFile = file;
validationResult = null;
importResult = null;
}
async function validateFile(): Promise<void> {
if (!importFile) return; if (!importFile) return;
validating = true; validating = true;
validationResult = null; validationResult = null;
@@ -183,12 +209,11 @@
} }
} }
// --- Import --- function doImport(): void {
async function doImport() {
confirmImportOpen = true; confirmImportOpen = true;
} }
async function performImport() { async function performImport(): Promise<void> {
confirmImportOpen = false; confirmImportOpen = false;
if (!importFile) return; if (!importFile) return;
importing = true; importing = true;
@@ -213,10 +238,10 @@
} }
// --- Scheduled settings --- // --- Scheduled settings ---
async function saveSchedule() { async function saveSchedule(): Promise<void> {
savingSchedule = true; savingSchedule = true;
try { try {
scheduledSettings = await api('/backup/scheduled', { scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(scheduledSettings), body: JSON.stringify(scheduledSettings),
}); });
@@ -229,10 +254,10 @@
} }
// --- File management --- // --- File management ---
async function refreshFiles() { async function refreshFiles(): Promise<void> {
loadingFiles = true; loadingFiles = true;
try { try {
backupFiles = await api('/backup/files'); backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
} finally { } finally {
@@ -240,7 +265,7 @@
} }
} }
async function downloadFile(filename: string) { async function downloadFile(filename: string): Promise<void> {
try { try {
const data = await api(`/backup/files/${filename}`); const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 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 { try {
await api(`/backup/files/${filename}`, { method: 'DELETE' }); await api(`/backup/files/${filename}`, { method: 'DELETE' });
snackSuccess(t('backup.fileDeleted')); snackSuccess(t('backup.fileDeleted'));
@@ -265,355 +290,61 @@
snackError(err.message); 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> </script>
<PageHeader <BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
{#if !loaded} {#if !loaded}
<Loading /> <Loading />
{:else} {:else}
<ErrorBanner message={error} /> <ErrorBanner message={error} />
{#if pending?.pending} <PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
<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);"> <div class="backup-page stagger-children">
<span style="color: var(--color-warning-fg); flex-shrink: 0;"> <div class="action-deck">
<MdiIcon name="mdiClockAlert" size={20} /> <ExportPanel
</span> {selectedCategories}
<div class="flex-1 min-w-[12rem] text-sm"> {exportSecrets}
<div class="font-medium">{t('backup.pendingTitle')}</div> {exporting}
<div class="text-xs break-words" style="color: var(--color-muted-foreground);"> onCategoriesChange={(next) => selectedCategories = next}
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')} onSecretsChange={(next) => exportSecrets = next}
</div> onExport={doExport}
</div> />
<div class="flex items-center gap-2 flex-wrap"> <ImportPanel
{#if pending.supervised} {importFile}
<Button size="sm" onclick={applyAndRestart}> {importConflict}
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')} {validating}
</Button> {validationResult}
{/if} {importing}
<button onclick={cancelPending} {importResult}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors"> onFileSelect={handleFileSelect}
{t('common.cancel')} onConflictChange={(mode) => importConflict = mode}
</button> onValidate={validateFile}
</div> onImport={doImport}
/>
</div> </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 --> <BackupLedger
<Card> files={backupFiles}
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2"> loading={loadingFiles}
<MdiIcon name="mdiDatabaseExport" size={18} /> creating={creatingBackup}
{t('backup.export')} onCreate={createManualBackup}
</h3> onRefresh={refreshFiles}
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p> onDownload={downloadFile}
onDelete={(filename) => confirmDeleteFile = filename}
<!-- 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>
</div> </div>
{/if} {/if}
@@ -652,27 +383,25 @@
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} /> <svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending} {#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop" <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} onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }} onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation"> role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1" <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()}> onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4"> <div class="post-restore-head">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0" <div class="post-restore-icon">
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<MdiIcon name="mdiClockAlert" size={22} /> <MdiIcon name="mdiClockAlert" size={22} />
</div> </div>
<div class="min-w-0"> <div class="post-restore-text">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3> <h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p> <p>{t('backup.restoreApplyPrompt')}</p>
</div> </div>
</div> </div>
<div class="flex gap-2 justify-end flex-wrap"> <div class="post-restore-actions">
<button onclick={() => postRestoreModalOpen = false} <button class="post-restore-later" type="button"
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors"> onclick={() => postRestoreModalOpen = false}>
{t('backup.applyLater')} {t('backup.applyLater')}
</button> </button>
{#if pending.supervised} {#if pending.supervised}
@@ -687,30 +416,162 @@
<!-- Restarting overlay --> <!-- Restarting overlay -->
{#if restartingOverlay} {#if restartingOverlay}
<div role="alert" aria-live="assertive" <div class="restart-overlay" 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="restart-card">
<div class="text-center p-6" style="color: var(--color-foreground);"> <div class="restart-spinner">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<MdiIcon name="mdiRestart" size={40} /> <MdiIcon name="mdiRestart" size={40} />
</div> </div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p> <p class="restart-title">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p> <p class="restart-sub">{t('backup.restartingDescription')}</p>
</div> </div>
</div> </div>
{/if} {/if}
<style> <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 { .restart-spinner {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-bottom: 0.85rem;
color: var(--color-primary);
animation: restart-spin 1.2s linear infinite; animation: restart-spin 1.2s linear infinite;
transform-origin: center center; 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 { @keyframes restart-spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@media (prefers-reduced-motion: reduce) {
.restart-spinner { animation: none !important; }
}
</style> </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>
+329 -51
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte'; import BlockedByModal from '$lib/components/BlockedByModal.svelte';
@@ -13,7 +15,6 @@
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { chatActionItems } from '$lib/grid-items'; import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
@@ -22,6 +23,7 @@
import TargetForm from './TargetForm.svelte'; import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte'; import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ── // ── Helpers ──
@@ -129,6 +131,7 @@
child_target_ids: [] as number[], child_target_ids: [] as number[],
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let error = $state(''); let error = $state('');
let loaded = $state(false); let loaded = $state(false);
let submitting = $state(false); let submitting = $state(false);
@@ -137,6 +140,17 @@
let confirmDelete = $state<NotificationTarget | null>(null); let confirmDelete = $state<NotificationTarget | null>(null);
let formEl = $state<HTMLElement | undefined>(); let formEl = $state<HTMLElement | undefined>();
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
broadcast: 'Broadcast',
};
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
}
});
async function scrollToForm() { async function scrollToForm() {
await tick(); await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }); formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -152,6 +166,20 @@
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null); let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({}); 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 ── // ── Effects ──
// Reset form when switching target type tabs // Reset form when switching target type tabs
@@ -167,6 +195,98 @@
onMount(load); 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 headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = []; const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) { if (activeType) {
@@ -204,6 +324,16 @@
} catch (e) { console.warn('Failed to load bot chats:', e); } } 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 ── // ── Target CRUD ──
function openNew() { function openNew() {
@@ -213,6 +343,7 @@
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id; if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id; if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id; if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
nameManuallyEdited = false;
editing = null; editing = null;
showTelegramSettings = false; showTelegramSettings = false;
showForm = true; showForm = true;
@@ -242,6 +373,7 @@
// broadcast // broadcast
child_target_ids: c.child_target_ids || [], child_target_ids: c.child_target_ids || [],
}; };
nameManuallyEdited = true;
editing = tgt.id; editing = tgt.id;
showTelegramSettings = false; showTelegramSettings = false;
showForm = true; showForm = true;
@@ -327,15 +459,27 @@
// ── Receiver CRUD ── // ── 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; addingReceiverForTarget = targetId;
expandTarget(targetId);
receiverHeadersError = ''; receiverHeadersError = '';
if (targetType === 'telegram') { if (targetType === 'telegram') {
receiverForm = { chat_id: '' }; 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 tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id; 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') { } else if (targetType === 'email') {
receiverForm = { email: '' }; receiverForm = { email: '' };
} else if (targetType === 'webhook') { } else if (targetType === 'webhook') {
@@ -439,7 +583,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')} title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')} emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')} description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets" crumb={t('crumbs.routingTargets')}
count={targets.length} count={targets.length}
countLabel={t('dashboard.targetsShort')} countLabel={t('dashboard.targetsShort')}
pills={headerPills} pills={headerPills}
@@ -476,6 +620,7 @@
bind:showTelegramSettings bind:showTelegramSettings
onsave={save} onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings} ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/> />
{/if} {/if}
@@ -495,53 +640,84 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} /> <EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card> </Card>
{:else} {:else}
<div class="space-y-3 stagger-children"> <div class="targets-list">
{#each targets as target (target.id)} {#each groupedTargets as group (group.key)}
<Card hover entityId={target.id}> <section class="target-group">
<!-- Target header --> <BotGroupHeader
<div class="flex items-center justify-between"> icon={group.icon}
<div> name={group.name}
<div class="flex items-center gap-2"> subtitle={group.subtitle}
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span> targetCount={group.targets.length}
<p class="font-medium">{target.name}</p> typeBadge={!activeType ? group.typeBadge : null}
{#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} botHref={group.botHref}
{#if target.type === 'broadcast' && target.child_targets?.length} botEntityId={group.botEntityId}
<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> muted={group.muted}
{: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}
/> />
</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} {/each}
</div> </div>
{/if} {/if}
@@ -563,3 +739,105 @@
/> />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} /> <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> </div>
{/each} {/each}
<!-- Inline add-receiver form --> <!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
{#if addingReceiverForTarget === target.id} {#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)]"> <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'} {#if target.type === 'email'}
{@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'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com" <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)]" /> 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'} {:else if target.type === 'webhook'}
@@ -49,6 +49,7 @@
showTelegramSettings: boolean; showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void; onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void; ontoggleTelegramSettings: () => void;
onnameinput?: () => void;
} }
let { let {
@@ -70,6 +71,7 @@
showTelegramSettings = $bindable(), showTelegramSettings = $bindable(),
onsave, onsave,
ontoggleTelegramSettings, ontoggleTelegramSettings,
onnameinput,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -87,7 +89,7 @@
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label> <label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> <input id="tgt-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
{#if formType === 'telegram'} {#if formType === 'telegram'}
@@ -28,6 +28,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import { getDescriptor } from '$lib/providers';
import type { TemplateConfig } from '$lib/types'; import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items); let allTemplateConfigs = $derived(templateConfigsCache.items);
@@ -194,8 +195,16 @@
date_only_format: '%d.%m.%Y', date_only_format: '%d.%m.%Y',
}); });
let form = $state(defaultForm()); let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let previewTargetType = $state('telegram'); let previewTargetType = $state('telegram');
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Templates` : 'Templates';
}
});
// Provider capabilities: from shared cache // Provider capabilities: from shared cache
let allCapabilities = $derived(capabilitiesCache.items); let allCapabilities = $derived(capabilitiesCache.items);
let providerTypes = $derived(Object.keys(allCapabilities)); let providerTypes = $derived(Object.keys(allCapabilities));
@@ -252,7 +261,25 @@
supportedLocalesCache.fetch(), supportedLocalesCache.fetch(),
]); ]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } } 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);
} }
/** /**
@@ -291,6 +318,7 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0]; if (providerTypes.length > 0) form.provider_type = providerTypes[0];
nameManuallyEdited = false;
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview(); refreshDateFormatPreview();
} }
@@ -304,6 +332,7 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC', date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y', date_only_format: c.date_only_format || '%d.%m.%Y',
}; };
nameManuallyEdited = true;
editing = c.id; showForm = true; activeLocale = primaryLocale; editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
@@ -418,7 +447,7 @@
title={t('templateConfig.title')} title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')} emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')} description={t('templateConfig.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={configs.length} count={configs.length}
countLabel={t('templateConfig.countLabel')} countLabel={t('templateConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -439,7 +468,7 @@
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label> <label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')} <input id="tpc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('templateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
@@ -190,6 +190,14 @@
}); });
let form: Record<string, any> = $state(defaultForm()); let form: Record<string, any> = $state(defaultForm());
let descriptor = $derived(getDescriptor(form.provider_type)); let descriptor = $derived(getDescriptor(form.provider_type));
let nameManuallyEdited = $state(false);
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
const desc = getDescriptor(form.provider_type);
form.name = desc ? `${desc.defaultName} Tracking` : 'Tracking';
}
});
onMount(() => { onMount(() => {
topbarAction.set({ topbarAction.set({
@@ -230,9 +238,10 @@
window.history.replaceState(null, '', cleanUrl); window.history.replaceState(null, '', cleanUrl);
} }
function openNew() { form = defaultForm(); editing = null; showForm = true; } function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
function edit(c: any) { function edit(c: any) {
form = { ...defaultForm(), ...c }; form = { ...defaultForm(), ...c };
nameManuallyEdited = true;
editing = c.id; showForm = true; editing = c.id; showForm = true;
} }
@@ -267,7 +276,7 @@
title={t('trackingConfig.title')} title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')} emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')} description={t('trackingConfig.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={configs.length} count={configs.length}
countLabel={t('trackingConfig.countLabel')} countLabel={t('trackingConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -288,7 +297,7 @@
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label> <label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} /> <IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')} <input id="tc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('trackingConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
</div> </div>
+1 -1
View File
@@ -93,7 +93,7 @@
title={t('users.title')} title={t('users.title')}
emphasis={t('users.titleEmphasis')} emphasis={t('users.titleEmphasis')}
description={t('users.description')} description={t('users.description')}
crumb="System · Access" crumb={t('crumbs.systemAccess')}
count={users.length} count={users.length}
countLabel={t('users.countLabel')} countLabel={t('users.countLabel')}
> >
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.6.5" version = "0.7.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -4,21 +4,24 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any from typing import Any, Final
import aiohttp import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Discord webhook content limit # Discord API constraints (per webhook docs).
MAX_CONTENT_LENGTH = 2000 MAX_CONTENT_LENGTH: Final = 2000
MAX_USERNAME_LENGTH: Final = 80
class DiscordClient: class DiscordClient(HttpProviderClient):
"""Sends messages via Discord webhook URLs.""" """Sends messages via Discord webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None: def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session super().__init__(session, provider_name="discord")
async def send( async def send(
self, self,
@@ -33,6 +36,8 @@ class DiscordClient:
""" """
if not webhook_url: if not webhook_url:
return {"success": False, "error": "Missing webhook_url"} return {"success": False, "error": "Missing webhook_url"}
if username and len(username) > MAX_USERNAME_LENGTH:
return {"success": False, "error": f"username exceeds {MAX_USERNAME_LENGTH} chars"}
chunks = _split_message(message, MAX_CONTENT_LENGTH) chunks = _split_message(message, MAX_CONTENT_LENGTH)
for chunk in chunks: for chunk in chunks:
@@ -42,71 +47,34 @@ class DiscordClient:
if avatar_url: if avatar_url:
payload["avatar_url"] = avatar_url payload["avatar_url"] = avatar_url
result = await self._post(webhook_url, payload) result = await self.request("POST", webhook_url, json=payload)
if not result["success"]: if not result.get("success"):
return result return result
# Small delay between chunks to respect rate limits
if len(chunks) > 1: if len(chunks) > 1:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
return {"success": True} return {"success": True}
_MAX_RETRIES = 3
_MAX_RETRY_AFTER = 60.0
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
"""POST with bounded 429 retry.
We cap retries at _MAX_RETRIES and the ``Retry-After`` header at
_MAX_RETRY_AFTER seconds so a hostile or misbehaving upstream cannot
pin the dispatch task indefinitely.
"""
for attempt in range(self._MAX_RETRIES + 1):
try:
async with self._session.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
allow_redirects=False,
) as resp:
if resp.status == 429 and attempt < self._MAX_RETRIES:
try:
retry_after = float(resp.headers.get("Retry-After", "2"))
except (TypeError, ValueError):
retry_after = 2.0
retry_after = max(0.0, min(retry_after, self._MAX_RETRY_AFTER))
_LOGGER.warning(
"Discord rate limited, retrying after %.1fs (attempt %d/%d)",
retry_after, attempt + 1, self._MAX_RETRIES,
)
await asyncio.sleep(retry_after)
continue
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {
"success": False,
"error": f"HTTP {resp.status}: {body[:200]}",
}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": "Rate limited (retries exhausted)"}
def _split_message(text: str, limit: int) -> list[str]: def _split_message(text: str, limit: int) -> list[str]:
"""Split message into chunks respecting the character limit.""" """Split message into chunks respecting the character limit.
Drops chunks that contain only whitespace Discord rejects those.
"""
if len(text) <= limit: if len(text) <= limit:
return [text] return [text]
chunks = [] chunks: list[str] = []
while text: while text:
if len(text) <= limit: if len(text) <= limit:
chunks.append(text) piece = text
break text = ""
# Try to split at newline else:
split_at = text.rfind("\n", 0, limit) split_at = text.rfind("\n", 0, limit)
if split_at <= 0: if split_at <= 0:
split_at = limit split_at = limit
chunks.append(text[:split_at]) piece = text[:split_at]
text = text[split_at:].lstrip("\n") text = text[split_at:].lstrip("\n")
return chunks if piece.strip():
chunks.append(piece)
return chunks or [text]
@@ -7,7 +7,7 @@ import contextlib
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, AsyncIterator from typing import Any, AsyncIterator, Awaitable, Callable, Final
import aiohttp import aiohttp
@@ -15,37 +15,20 @@ from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.templates.context import build_template_context from notify_bridge_core.templates.context import build_template_context
from notify_bridge_core.templates.renderer import render_template from notify_bridge_core.templates.renderer import render_template
from .ssrf import UnsafeURLError, avalidate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
# contains many large videos.
_PRELOAD_CONCURRENCY = 6
def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout.
We still open a short-lived session per dispatch (connection reuse across
dispatches lives in the server-side shared session), but we always attach
a total timeout so a hung peer cannot wedge the task forever.
"""
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
from .http_base import safe_headers
from .receiver import ( from .receiver import (
DiscordReceiver,
EmailReceiver,
MatrixReceiver,
NtfyReceiver,
Receiver, Receiver,
SlackReceiver,
TelegramReceiver, TelegramReceiver,
WebhookReceiver, WebhookReceiver,
EmailReceiver,
DiscordReceiver,
SlackReceiver,
NtfyReceiver,
MatrixReceiver,
) )
from .redact import redact_exc
from .ssrf import UnsafeURLError, avalidate_outbound_url
from .telegram.cache import TelegramFileCache from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient from .telegram.client import TelegramClient
from .telegram.media import ( from .telegram.media import (
@@ -58,7 +41,33 @@ from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"' DEFAULT_TEMPLATE: Final = '{{ event_type }}: "{{ collection_name }}"'
_HTTP_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``.
_PRELOAD_CONCURRENCY: Final = 6
# Cap on how many targets the dispatcher fans out to at once. With dozens
# of targets and a single hung peer, unbounded ``gather`` can pin the
# dispatch task. The cap also protects against credential-reuse rate
# limits on shared providers.
_DISPATCH_CONCURRENCY: Final = 16
# Cap on parallel per-receiver sends within a single target.
_RECEIVER_CONCURRENCY: Final = 8
# Per-target soft timeout — at the top of the dispatch tree so a single
# misbehaving target can't hold the whole batch open. Individual provider
# clients carry their own per-request timeouts on top of this.
_TARGET_TIMEOUT_S: Final = 120.0
def _new_session() -> aiohttp.ClientSession:
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
@dataclass @dataclass
@@ -66,17 +75,23 @@ class TargetConfig:
"""Configuration for a notification target.""" """Configuration for a notification target."""
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix" type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
config: dict[str, Any] # target-level config (bot_token, settings, etc.) config: dict[str, Any]
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template} template_slots: dict[str, dict[str, str]] | None = None
locale: str = "en" # default locale for template resolution locale: str = "en"
date_format: str = "%d.%m.%Y, %H:%M UTC" date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y" date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider provider_api_key: str | None = None
provider_internal_url: str | None = None # Internal provider URL for API key scoping provider_internal_url: str | None = None
provider_external_url: str | None = None # External domain for API key scoping provider_external_url: str | None = None
receivers: list[Receiver] = field(default_factory=list) receivers: list[Receiver] = field(default_factory=list)
_SendMethod = Callable[
["NotificationDispatcher", TargetConfig, str, ServiceEvent],
Awaitable[dict[str, Any]],
]
class NotificationDispatcher: class NotificationDispatcher:
"""Dispatches ServiceEvent notifications to configured targets.""" """Dispatches ServiceEvent notifications to configured targets."""
@@ -90,18 +105,11 @@ class NotificationDispatcher:
self._url_cache = url_cache self._url_cache = url_cache
self._asset_cache = asset_cache self._asset_cache = asset_cache
# Optional shared session owned by the caller; when supplied we reuse # Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session # its connection pool instead of opening a fresh per-dispatch session.
# (saves a TLS handshake per outbound call).
self._shared_session = session self._shared_session = session
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]: async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
"""Yield an aiohttp session, reusing the shared one if provided.
When a shared session was passed in ``__init__`` we yield it without
closing (the caller owns its lifetime). Otherwise we open a
short-lived session with our default timeout and close it on exit.
"""
if self._shared_session is not None and not self._shared_session.closed: if self._shared_session is not None and not self._shared_session.closed:
yield self._shared_session yield self._shared_session
return return
@@ -115,11 +123,9 @@ class NotificationDispatcher:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Send event notification to all targets. """Send event notification to all targets.
Returns list of results (one per target). Returns one result per target. Per-target failures are isolated;
a single bad target cannot poison the batch.
""" """
# Bind a dispatch_id so every log line emitted by the target sends
# (including deep in TelegramClient) can be correlated to the same
# upstream event.
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}" new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
with bind_log_context(dispatch_id=new_id): with bind_log_context(dispatch_id=new_id):
@@ -128,20 +134,36 @@ class NotificationDispatcher:
event.event_type.value if hasattr(event.event_type, "value") else event.event_type, event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
getattr(event, "collection_name", None), len(targets), getattr(event, "collection_name", None), len(targets),
) )
sem = asyncio.Semaphore(_DISPATCH_CONCURRENCY)
async def run_one(t: TargetConfig) -> dict[str, Any]:
async with sem:
try:
return await asyncio.wait_for(
self._send_to_target(event, t),
timeout=_TARGET_TIMEOUT_S,
)
except asyncio.TimeoutError:
return {
"success": False,
"error": f"Target dispatch timed out after {_TARGET_TIMEOUT_S}s",
}
raw_results = await asyncio.gather( raw_results = await asyncio.gather(
*[self._send_to_target(event, t) for t in targets], *[run_one(t) for t in targets],
return_exceptions=True, return_exceptions=True,
) )
results = [] results: list[dict[str, Any]] = []
failures = 0 failures = 0
for target, raw in zip(targets, raw_results): for target, raw in zip(targets, raw_results):
if isinstance(raw, Exception): if isinstance(raw, Exception):
failures += 1 failures += 1
_LOGGER.error( _LOGGER.error(
"Dispatch to target type=%s failed: %s", "Dispatch to target type=%s failed: %s",
target.type, raw, exc_info=raw, target.type, redact_exc(raw),
) )
results.append({"success": False, "error": str(raw)}) results.append({"success": False, "error": redact_exc(raw)})
else: else:
if isinstance(raw, dict) and not raw.get("success"): if isinstance(raw, dict) and not raw.get("success"):
failures += 1 failures += 1
@@ -155,7 +177,6 @@ class NotificationDispatcher:
def _resolve_template( def _resolve_template(
self, event: ServiceEvent, target: TargetConfig, locale: str, self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str: ) -> str:
"""Resolve template string for an event, with locale fallback."""
template_str = DEFAULT_TEMPLATE template_str = DEFAULT_TEMPLATE
if target.template_slots: if target.template_slots:
locale_map = target.template_slots.get(event.event_type.value) locale_map = target.template_slots.get(event.event_type.value)
@@ -166,7 +187,6 @@ class NotificationDispatcher:
def _render_message( def _render_message(
self, event: ServiceEvent, target: TargetConfig, locale: str, self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str: ) -> str:
"""Resolve template and render message for a given locale."""
template_str = self._resolve_template(event, target, locale) template_str = self._resolve_template(event, target, locale)
ctx = build_template_context( ctx = build_template_context(
event, target_type=target.type, event, target_type=target.type,
@@ -179,7 +199,6 @@ class NotificationDispatcher:
self, receiver: Receiver, default_message: str, self, receiver: Receiver, default_message: str,
event: ServiceEvent, target: TargetConfig, event: ServiceEvent, target: TargetConfig,
) -> str: ) -> str:
"""Return per-receiver message, re-rendering if receiver has a different locale."""
if receiver.locale and receiver.locale != target.locale: if receiver.locale and receiver.locale != target.locale:
return self._render_message(event, target, receiver.locale) return self._render_message(event, target, receiver.locale)
return default_message return default_message
@@ -187,21 +206,16 @@ class NotificationDispatcher:
async def _send_to_target( async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send event to a single target (potentially multiple receivers).""" """Dispatch to a single target via the registered handler."""
default_message = self._render_message(event, target, target.locale) default_message = self._render_message(event, target, target.locale)
send_method = _PROVIDER_HANDLERS.get(target.type)
if send_method is None:
return {"success": False, "error": f"Unknown target type: {target.type}"}
return await send_method(self, target, default_message, event)
send_method = { # ------------------------------------------------------------------
"telegram": self._send_telegram, # Asset preload (Telegram-specific)
"webhook": self._send_webhook, # ------------------------------------------------------------------
"email": self._send_email,
"discord": self._send_discord,
"slack": self._send_slack,
"ntfy": self._send_ntfy,
"matrix": self._send_matrix,
}.get(target.type)
if send_method:
return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _preload_asset_data( async def _preload_asset_data(
self, self,
@@ -210,36 +224,13 @@ class NotificationDispatcher:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
max_size: int | None, max_size: int | None,
) -> None: ) -> None:
"""Download each non-cached asset's bytes once and attach to the entry. """Download each non-cached asset's bytes once, with SSRF guard."""
Three benefits:
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
so we don't fetch each URL twice.
* We know the exact upload size, which lets the oversize warning in
the rendered text compare against real bytes (for Immich videos,
the transcoded ``/video/playback``), not the original ``file_size``.
* Assets already in the Telegram file_id cache are skipped, and their
stored size (if any) is used to populate ``playback_size`` so
templates see consistent sizes for repeat sends without re-download.
Entries whose download fails or exceeds ``max_size`` are left without
``data``; ``TelegramClient`` will then fall back to its own download
path and apply the same checks no regression, just no preload win.
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
stays predictable: at most N assets worth of bytes held in RAM at
once, regardless of ``max_media_to_send``. Total wall-clock is
unchanged for small batches and only marginally slower for large
ones (most assets fit in a single RTT and SSL negotiation cost
dominates, so 6-way parallelism is sufficient).
"""
if not assets: if not assets:
return return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY) sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None: async def fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
cache, key = self._cache_for_entry(entry) cache, key = self._cache_for_entry(entry)
if cache and key: if cache and key:
cached = cache.get(key) cached = cache.get(key)
@@ -251,28 +242,40 @@ class NotificationDispatcher:
url = entry["url"] url = entry["url"]
headers = entry.get("headers") or {} headers = entry.get("headers") or {}
try:
# Defense-in-depth: validate even though TelegramClient
# also validates. The dispatcher is what triggers the
# download, so the guard belongs here too.
await avalidate_outbound_url(url)
except UnsafeURLError as err:
_LOGGER.warning(
"Asset preload skipped: unsafe URL (%s)", redact_exc(err),
)
return
async with sem: async with sem:
try: try:
async with session.get(url, headers=headers) as resp: async with session.get(url, headers=headers) as resp:
if resp.status != 200: if resp.status != 200:
return return
data = await resp.read() data = await resp.read()
except aiohttp.ClientError: except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
return return
if max_size is not None and len(data) > max_size: if max_size is not None and len(data) > max_size:
return return
entry["data"] = data entry["data"] = data
media.extra["playback_size"] = len(data) media.extra["playback_size"] = len(data)
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets))) raw = await asyncio.gather(
*(fetch(e, m) for e, m in zip(assets, media_assets)),
return_exceptions=True,
)
for r in raw:
if isinstance(r, Exception):
_LOGGER.warning("Asset preload raised: %s", redact_exc(r))
def _cache_for_entry( def _cache_for_entry(
self, entry: dict[str, Any], self, entry: dict[str, Any],
) -> tuple[TelegramFileCache | None, str | None]: ) -> tuple[TelegramFileCache | None, str | None]:
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
Returns (None, None) if no cache is configured or no key can be derived.
"""
cache_key = entry.get("cache_key") cache_key = entry.get("cache_key")
if cache_key: if cache_key:
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
@@ -287,6 +290,10 @@ class NotificationDispatcher:
return self._url_cache, url return self._url_cache, url
return None, None return None, None
# ------------------------------------------------------------------
# Per-provider handlers
# ------------------------------------------------------------------
async def _send_telegram( async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -296,27 +303,25 @@ class NotificationDispatcher:
max_media = target.config.get("max_media_to_send", 50) max_media = target.config.get("max_media_to_send", 50)
max_group = target.config.get("max_media_per_group", 10) max_group = target.config.get("max_media_per_group", 10)
chunk_delay = target.config.get("media_delay", 500) chunk_delay = target.config.get("media_delay", 500)
max_size = target.config.get("max_asset_size") max_size_mb = target.config.get("max_asset_size")
if max_size: max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb else None
max_size = max_size * 1024 * 1024 # MB to bytes
send_large_as_docs = target.config.get("send_large_photos_as_documents", False) send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
if not bot_token: if not bot_token:
return {"success": False, "error": "Missing bot_token"} return {"success": False, "error": "Missing bot_token"}
if not target.receivers: if not target.receivers:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
# Prepare assets list once (shared across receivers)
# Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/") internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/") external_url = (target.provider_external_url or "").rstrip("/")
assets = [] assets: list[dict[str, Any]] = []
media_assets: list[Any] = [] # aligned with `assets` for preload media_assets: list[Any] = []
for asset in event.added_assets[:max_media]: for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url url = asset.preview_url or asset.thumbnail_url or asset.full_url
if not url:
continue
asset_entry = build_telegram_asset_entry( asset_entry = build_telegram_asset_entry(
url=url or "", url=url,
media_type=asset.type.value, media_type=asset.type.value,
api_key=target.provider_api_key, api_key=target.provider_api_key,
internal_url=internal_url, internal_url=internal_url,
@@ -327,26 +332,15 @@ class NotificationDispatcher:
assets.append(asset_entry) assets.append(asset_entry)
media_assets.append(asset) media_assets.append(asset)
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its await self._preload_asset_data(assets, media_assets, session, max_size_bytes)
# own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
# Asset cache (when in thumbhash mode) invalidates entries when the
# asset's visual content changes. The resolver maps asset id → its
# current thumbhash. Providers that expose thumbhash put it in
# ``asset.extra["thumbhash"]`` (currently Immich).
thumbhash_map = { thumbhash_map = {
asset.id: asset.extra.get("thumbhash") asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets for asset in event.added_assets
if asset.extra.get("thumbhash") if asset.extra.get("thumbhash")
} }
thumbhash_resolver = ( thumbhash_resolver = thumbhash_map.get if thumbhash_map else None
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
client = TelegramClient( client = TelegramClient(
session, bot_token, session, bot_token,
@@ -355,39 +349,51 @@ class NotificationDispatcher:
thumbhash_resolver=thumbhash_resolver, thumbhash_resolver=thumbhash_resolver,
) )
for receiver in target.receivers: async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id: if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
results.append({"success": False, "error": "Invalid telegram receiver"}) return {"success": False, "error": "Invalid telegram receiver"}
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
text_result = await client.send_message( text_result = await client.send_message(
chat_id=receiver.chat_id, chat_id=receiver.chat_id,
text=message, text=message,
disable_web_page_preview=bool(disable_preview), disable_web_page_preview=bool(disable_preview),
) )
if not text_result.get("success"): if not text_result.get("success"):
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error")) _LOGGER.warning(
results.append(text_result) "Failed to send to chat %s: %s",
continue receiver.chat_id, text_result.get("error"),
)
return text_result
if assets: if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification( media_result = await client.send_notification(
chat_id=receiver.chat_id, chat_id=receiver.chat_id,
assets=assets, assets=assets,
reply_to_message_id=reply_to, reply_to_message_id=text_result.get("message_id"),
max_group_size=max_group, max_group_size=max_group,
chunk_delay=chunk_delay, chunk_delay=chunk_delay,
max_asset_data_size=max_size, max_asset_data_size=max_size_bytes,
send_large_photos_as_documents=send_large_as_docs, send_large_photos_as_documents=send_large_as_docs,
chat_action=chat_action or None, chat_action=chat_action or None,
) )
if not media_result.get("success"): if not media_result.get("success"):
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error")) _LOGGER.warning(
"Text sent OK but media failed for chat %s: %s",
receiver.chat_id, media_result.get("error"),
)
# Preserve both outcomes — text succeeded, media
# didn't. Operators losing media-failure detail
# in the result dict made root-cause analysis
# impossible.
return {
"success": True,
"message_id": text_result.get("message_id"),
"media_error": media_result.get("error"),
"media_failed_at_chunk": media_result.get("failed_at_chunk"),
}
return text_result
results.append(text_result) results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
@@ -397,17 +403,10 @@ class NotificationDispatcher:
if not target.receivers: if not target.receivers:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
for receiver in target.receivers: async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, WebhookReceiver) or not receiver.url: if not isinstance(receiver, WebhookReceiver) or not receiver.url:
results.append({"success": False, "error": "Invalid webhook receiver"}) return {"success": False, "error": "Invalid webhook receiver"}
continue
try:
await avalidate_outbound_url(receiver.url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
payload = { payload = {
"message": message, "message": message,
@@ -417,8 +416,10 @@ class NotificationDispatcher:
"collection_id": event.collection_id, "collection_id": event.collection_id,
"timestamp": event.timestamp.isoformat(), "timestamp": event.timestamp.isoformat(),
} }
client = WebhookClient(session, receiver.url, receiver.headers) client = WebhookClient(session, receiver.url, safe_headers(receiver.headers))
results.append(await client.send(payload)) return await client.send(payload)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
@@ -431,7 +432,7 @@ class NotificationDispatcher:
if not smtp_cfg.get("host"): if not smtp_cfg.get("host"):
return {"success": False, "error": "SMTP not configured"} return {"success": False, "error": "SMTP not configured"}
client = EmailClient(SmtpConfig( email_client = EmailClient(SmtpConfig(
host=smtp_cfg["host"], host=smtp_cfg["host"],
port=int(smtp_cfg.get("port", 587)), port=int(smtp_cfg.get("port", 587)),
username=smtp_cfg.get("username", ""), username=smtp_cfg.get("username", ""),
@@ -439,27 +440,28 @@ class NotificationDispatcher:
from_address=smtp_cfg.get("from_address", ""), from_address=smtp_cfg.get("from_address", ""),
from_name=smtp_cfg.get("from_name", "Notify Bridge"), from_name=smtp_cfg.get("from_name", "Notify Bridge"),
use_tls=smtp_cfg.get("use_tls", True), use_tls=smtp_cfg.get("use_tls", True),
tls_mode=smtp_cfg.get("tls_mode", "auto"),
)) ))
if not target.receivers: if not target.receivers:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}" subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = [] async def send_one(receiver: Receiver) -> dict[str, Any]:
for receiver in target.receivers:
if not isinstance(receiver, EmailReceiver) or not receiver.email: if not isinstance(receiver, EmailReceiver) or not receiver.email:
results.append({"success": False, "error": "Invalid email receiver"}) return {"success": False, "error": "Invalid email receiver"}
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
result = await client.send( # body_html=None lets EmailClient build a safely-escaped HTML
# alternative from body_text instead of trusting user content.
return await email_client.send(
to_email=receiver.email, to_email=receiver.email,
subject=subject, subject=subject,
body_text=message, body_text=message,
body_html=message, body_html=None,
to_name=receiver.name, to_name=receiver.name,
) )
results.append(result)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
async def _send_discord( async def _send_discord(
@@ -471,20 +473,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
username = target.config.get("username") username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
client = DiscordClient(session) client = DiscordClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url: if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid discord receiver"}) return {"success": False, "error": "Invalid discord receiver"}
continue
try:
await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username)) return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
@@ -497,20 +495,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
username = target.config.get("username") username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
client = SlackClient(session) client = SlackClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url: if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
results.append({"success": False, "error": "Invalid slack receiver"}) return {"success": False, "error": "Invalid slack receiver"}
continue
try:
await avalidate_outbound_url(receiver.webhook_url)
except UnsafeURLError as err:
results.append({"success": False, "error": f"Unsafe URL: {err}"})
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(receiver.webhook_url, message, username=username)) return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
@@ -526,22 +520,23 @@ class NotificationDispatcher:
try: try:
await avalidate_outbound_url(server_url) await avalidate_outbound_url(server_url)
except UnsafeURLError as err: except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"} return {"success": False, "error": f"Unsafe ntfy server_url: {redact_exc(err)}"}
title = f"{event.event_type.value}: {event.collection_name}" title = f"{event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
client = NtfyClient(session) client = NtfyClient(session)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic: if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
results.append({"success": False, "error": "Invalid ntfy receiver"}) return {"success": False, "error": "Invalid ntfy receiver"}
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send( return await client.send(
server_url, receiver.topic, message, server_url, receiver.topic, message,
title=title, priority=receiver.priority, auth_token=auth_token, title=title, priority=receiver.priority, auth_token=auth_token,
)) )
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
@@ -557,33 +552,108 @@ class NotificationDispatcher:
try: try:
await avalidate_outbound_url(homeserver) await avalidate_outbound_url(homeserver)
except UnsafeURLError as err: except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"} return {"success": False, "error": f"Unsafe matrix homeserver_url: {redact_exc(err)}"}
if not target.receivers: if not target.receivers:
return {"success": False, "error": "No receivers configured"} return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with self._session_ctx() as session: async with self._session_ctx() as session:
client = MatrixClient(session, homeserver, access_token) client = MatrixClient(session, homeserver, access_token)
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id: if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
results.append({"success": False, "error": "Invalid matrix receiver"}) return {"success": False, "error": "Invalid matrix receiver"}
continue
message = self._message_for_receiver(receiver, default_message, event, target) message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send_message( # body_html is the same plain text — Matrix accepts the
receiver.room_id, message, html_message=message, # raw message as both ``body`` and ``formatted_body``.
)) # If templates emit HTML in the future, generate a
# separate HTML body upstream rather than aliasing here.
return await client.send_message(
receiver.room_id, message, html_message=None,
)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results) return self._aggregate_results(results)
# ------------------------------------------------------------------
# Aggregation
# ------------------------------------------------------------------
@staticmethod
async def _fan_out(
receivers: list[Receiver],
send_one: Callable[[Receiver], Awaitable[dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Run ``send_one`` per receiver with bounded concurrency.
Per-receiver exceptions are converted to failure dicts so a single
bad receiver can't cancel its peers.
"""
sem = asyncio.Semaphore(_RECEIVER_CONCURRENCY)
async def guarded(receiver: Receiver) -> dict[str, Any]:
async with sem:
try:
return await send_one(receiver)
except Exception as exc: # noqa: BLE001
_LOGGER.error("Receiver send raised: %s", redact_exc(exc))
return {"success": False, "error": redact_exc(exc)}
return await asyncio.gather(*(guarded(r) for r in receivers))
@staticmethod @staticmethod
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]: def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""Aggregate broadcast results into a single result dict.""" """Aggregate per-receiver results into a single target-level result.
Preserves the per-receiver detail under ``receivers`` so a caller
can see exactly which receivers failed, instead of getting only
the first error.
"""
if not results:
return {"success": False, "error": "No receivers configured"}
successes = sum(1 for r in results if r.get("success")) successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results: failures = len(results) - successes
return {"success": True, "receivers": len(results)} out: dict[str, Any] = {
elif successes > 0: "success": successes > 0,
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes} "receivers": len(results),
elif results: "successes": successes,
return results[0] "failures": failures,
return {"success": False, "error": "No receivers configured"} "results": results,
}
if failures:
out["errors"] = [
r.get("error") for r in results if not r.get("success")
]
if successes == 0:
# Surface the first error at the top level for back-compat
# with callers that only check ``error``.
out["error"] = results[0].get("error", "All receivers failed")
return out
# ----------------------------------------------------------------------
# Provider registry — replaces the if/elif chain so adding a provider
# means just registering it here, not editing dispatch logic.
# ----------------------------------------------------------------------
_PROVIDER_HANDLERS: dict[str, _SendMethod] = {
"telegram": NotificationDispatcher._send_telegram,
"webhook": NotificationDispatcher._send_webhook,
"email": NotificationDispatcher._send_email,
"discord": NotificationDispatcher._send_discord,
"slack": NotificationDispatcher._send_slack,
"ntfy": NotificationDispatcher._send_ntfy,
"matrix": NotificationDispatcher._send_matrix,
}
def register_provider(name: str, handler: _SendMethod) -> None:
"""Register a new dispatcher provider at runtime.
Allows out-of-tree providers to extend the dispatcher without
forking. The handler must follow the
``async (dispatcher, target, default_message, event) -> dict`` shape.
"""
_PROVIDER_HANDLERS[name] = handler
@@ -2,14 +2,32 @@
from __future__ import annotations from __future__ import annotations
import html
import logging import logging
import re
import ssl
from dataclasses import dataclass from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart from email.headerregistry import Address
from email.mime.text import MIMEText from email.message import EmailMessage
from typing import Any from typing import Any, Final, Literal
try: # Optional dependency — fail at first send rather than at import.
import aiosmtplib
from aiosmtplib import SMTPException
except ImportError: # pragma: no cover
aiosmtplib = None # type: ignore[assignment]
class SMTPException(Exception): # type: ignore[no-redef]
pass
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S: Final = 30.0
_TlsMode = Literal["auto", "implicit", "starttls", "none"]
# RFC 5322 lite: catches the obvious-bad addresses ("foo bar", "no-at",
# embedded CRLF) without pretending to fully validate addresses.
_EMAIL_RE: Final = re.compile(r"^[^\s@\r\n,;<>]+@[^\s@\r\n,;<>]+\.[^\s@\r\n,;<>]+$")
@dataclass @dataclass
class SmtpConfig: class SmtpConfig:
@@ -22,6 +40,55 @@ class SmtpConfig:
from_address: str = "" from_address: str = ""
from_name: str = "Notify Bridge" from_name: str = "Notify Bridge"
use_tls: bool = True use_tls: bool = True
# Explicit TLS mode. ``auto`` (back-compat) infers from ``use_tls`` and
# ``port``: 465 → implicit; 587 with use_tls=False → starttls; 25 → none.
tls_mode: _TlsMode = "auto"
timeout_s: float = _DEFAULT_TIMEOUT_S
def _strip_header(value: str) -> str:
"""Reject CRLF and bare CR/LF in header-bound strings.
SMTP header injection turns user-controlled subject/name strings into
arbitrary headers (``\\r\\nBcc: attacker@x``). The Python stdlib
accepts CRLF when followed by SP/HT (header folding), so explicit
sanitization is required even though :class:`EmailMessage` does some
validation of its own.
"""
return re.sub(r"[\r\n]+", " ", str(value or "")).strip()
def _validate_email(addr: str) -> str:
addr = _strip_header(addr)
if not addr:
raise ValueError("email address is empty")
if not _EMAIL_RE.match(addr):
raise ValueError("email address is invalid")
return addr
def _resolve_tls(cfg: SmtpConfig) -> tuple[bool, bool]:
"""Resolve ``(use_tls, start_tls)`` flags from the config.
``tls_mode`` overrides ``use_tls``/port heuristics when provided.
"""
mode = cfg.tls_mode
if mode == "implicit":
return True, False
if mode == "starttls":
return False, True
if mode == "none":
return False, False
# auto — preserve the historical "use_tls bool + port heuristic" behavior
# but make the path explicit.
if cfg.use_tls:
return True, False
return False, cfg.port != 25
def _to_html(text: str) -> str:
"""Convert plain text to a minimal HTML body, escaped for safety."""
return "<html><body><pre>" + html.escape(text) + "</pre></body></html>"
class EmailClient: class EmailClient:
@@ -30,30 +97,39 @@ class EmailClient:
def __init__(self, smtp_config: SmtpConfig) -> None: def __init__(self, smtp_config: SmtpConfig) -> None:
self._config = smtp_config self._config = smtp_config
@staticmethod
def _ssl_context() -> ssl.SSLContext:
# Explicit context so the TLS posture is auditable; aiosmtplib
# defaults look correct today but past regressions (and downstream
# repackaging) make implicit reliance fragile.
return ssl.create_default_context()
async def verify_connection(self) -> dict[str, Any]: async def verify_connection(self) -> dict[str, Any]:
"""Test SMTP connection and authentication without sending an email.""" """Test SMTP connection and authentication without sending an email."""
try: if aiosmtplib is None:
import aiosmtplib
except ImportError:
return {"success": False, "error": "aiosmtplib not installed"} return {"success": False, "error": "aiosmtplib not installed"}
cfg = self._config cfg = self._config
if not cfg.host: if not cfg.host:
return {"success": False, "error": "SMTP host not configured"} return {"success": False, "error": "SMTP host not configured"}
use_tls, start_tls = _resolve_tls(cfg)
try: try:
smtp = aiosmtplib.SMTP( smtp = aiosmtplib.SMTP(
hostname=cfg.host, hostname=cfg.host,
port=cfg.port, port=cfg.port,
use_tls=cfg.use_tls, use_tls=use_tls,
start_tls=not cfg.use_tls and cfg.port != 25, start_tls=start_tls,
tls_context=self._ssl_context(),
timeout=cfg.timeout_s,
validate_certs=True,
) )
await smtp.connect() await smtp.connect()
if cfg.username and cfg.password: if cfg.username and cfg.password:
await smtp.login(cfg.username, cfg.password) await smtp.login(cfg.username, cfg.password)
await smtp.quit() await smtp.quit()
return {"success": True} return {"success": True}
except Exception as e: except (SMTPException, OSError) as e:
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e) _LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -65,27 +141,52 @@ class EmailClient:
body_html: str | None = None, body_html: str | None = None,
to_name: str = "", to_name: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}.""" """Send an email.
try:
import aiosmtplib Returns ``{"success": True}`` or ``{"success": False, "error": "..."}``.
except ImportError:
``body_html`` is treated as already-safe markup. Pass ``None`` to
derive a safe HTML alternative from ``body_text`` automatically.
"""
if aiosmtplib is None:
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"} return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
cfg = self._config cfg = self._config
if not cfg.host or not cfg.from_address: if not cfg.host or not cfg.from_address:
return {"success": False, "error": "SMTP not configured (missing host or from_address)"} return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
# Build email message try:
msg = MIMEMultipart("alternative") to_addr = _validate_email(to_email)
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address from_addr = _validate_email(cfg.from_address)
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email except ValueError as exc:
msg["Subject"] = subject return {"success": False, "error": f"Invalid email address: {exc}"}
msg.attach(MIMEText(body_text, "plain", "utf-8")) # EmailMessage with structured Address objects rejects CRLF and
if body_html: # framework-folds long headers safely. We still strip first because
msg.attach(MIMEText(body_html, "html", "utf-8")) # EmailMessage's display-name slot is a pure string.
msg = EmailMessage()
from_display = _strip_header(cfg.from_name) or ""
to_display = _strip_header(to_name) or ""
try:
from_user, _, from_domain = from_addr.partition("@")
to_user, _, to_domain = to_addr.partition("@")
msg["From"] = Address(from_display, from_user, from_domain) if from_display else from_addr
msg["To"] = Address(to_display, to_user, to_domain) if to_display else to_addr
except ValueError as exc:
return {"success": False, "error": f"Invalid email address: {exc}"}
msg["Subject"] = _strip_header(subject)
msg.set_content(body_text or "", subtype="plain", charset="utf-8")
# If the caller provided HTML explicitly, honor it; otherwise build a
# safe escaped version so a stray "<" in the rendered template can't
# break the markup.
msg.add_alternative(
body_html if body_html is not None else _to_html(body_text or ""),
subtype="html",
charset="utf-8",
)
use_tls, start_tls = _resolve_tls(cfg)
try: try:
await aiosmtplib.send( await aiosmtplib.send(
msg, msg,
@@ -93,11 +194,14 @@ class EmailClient:
port=cfg.port, port=cfg.port,
username=cfg.username or None, username=cfg.username or None,
password=cfg.password or None, password=cfg.password or None,
use_tls=cfg.use_tls, use_tls=use_tls,
start_tls=not cfg.use_tls and cfg.port != 25, start_tls=start_tls,
tls_context=self._ssl_context(),
timeout=cfg.timeout_s,
validate_certs=True,
) )
_LOGGER.info("Email sent to %s", to_email) _LOGGER.info("Email sent to %s", to_addr)
return {"success": True} return {"success": True}
except Exception as e: except (SMTPException, OSError) as e:
_LOGGER.error("Failed to send email to %s: %s", to_email, e) _LOGGER.error("Failed to send email to %s: %s", to_addr, e)
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -0,0 +1,196 @@
"""Shared HTTP infrastructure for notification provider clients.
Slack/Discord/ntfy/Matrix/Webhook all follow the same pattern: build a
JSON payload, POST/PUT it, decode 200-range as success, decode 4xx/5xx
into a stable error dict, and retry transient 429/503 responses with a
capped ``Retry-After``. ``HttpProviderClient`` centralizes that pattern
so every provider gets the same SSRF guard, timeouts, secret-redacted
errors, and bounded retry policy by construction adding a new
provider doesn't get to forget any one of them.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Final, Mapping
import aiohttp
from .redact import redact, redact_exc
from .ssrf import UnsafeURLError, avalidate_outbound_url
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
_MAX_RETRIES: Final = 3
_MAX_RETRY_AFTER_S: Final = 60.0
_RETRY_STATUSES: Final = frozenset({429, 503})
# Hop-by-hop / framing headers a caller must not be able to override via
# user-supplied target config. Letting them through enables request
# smuggling, host-header bypasses of WAFs, and cache poisoning.
_FORBIDDEN_HEADERS: Final = frozenset({
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"te",
"upgrade",
"expect",
"proxy-authorization",
"proxy-connection",
})
def safe_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
"""Return a copy of ``headers`` with hop-by-hop/forbidden names dropped
and CRLF-bearing values rejected.
A target config that lets a user inject ``"X-Foo": "bar\\r\\nHost: evil"``
can perform request smuggling depending on aiohttp's framing. We strip
those values rather than letting them reach the wire.
"""
if not headers:
return {}
safe: dict[str, str] = {}
for raw_name, raw_value in headers.items():
name = str(raw_name).strip()
if not name or name.lower() in _FORBIDDEN_HEADERS:
continue
if any(c in name for c in "\r\n:"):
continue
value = str(raw_value)
if "\r" in value or "\n" in value:
continue
safe[name] = value
return safe
def make_error(message: str, *, status: int | None = None, body: str | None = None) -> dict[str, Any]:
"""Build a stable failure dict shape used by every provider client."""
err: dict[str, Any] = {"success": False, "error": redact(message)}
if status is not None:
err["status_code"] = status
if body:
err["body"] = redact(body)[:200]
return err
def make_success(**extra: Any) -> dict[str, Any]:
"""Build a stable success dict shape used by every provider client."""
out: dict[str, Any] = {"success": True}
out.update(extra)
return out
def _retry_after_seconds(headers: Mapping[str, str], cap_s: float) -> float:
raw = headers.get("Retry-After") or headers.get("retry-after") or "2"
try:
seconds = float(raw)
except (TypeError, ValueError):
seconds = 2.0
return max(0.0, min(seconds, cap_s))
class HttpProviderClient:
"""Base for JSON-over-HTTP notification providers.
Subclasses call :meth:`request` instead of using ``self._session``
directly. ``request`` runs the SSRF guard (skippable for known-safe
upstreams via ``ssrf_validate=False``), enforces a per-request
timeout, retries 429/503 with a capped ``Retry-After``, and turns
transport/HTTP errors into the canonical ``{"success": False, ...}``
shape with secrets redacted.
"""
_max_retries: int = _MAX_RETRIES
# Settable per-instance so tests / hostile-upstream tuning can
# tighten the cap. Reads of this attribute fall through to the
# class default when no instance value has been set.
_MAX_RETRY_AFTER: float = _MAX_RETRY_AFTER_S
def __init__(
self,
session: aiohttp.ClientSession,
*,
timeout: aiohttp.ClientTimeout | None = None,
provider_name: str = "http",
) -> None:
self._session = session
self._timeout = timeout or _DEFAULT_TIMEOUT
self._provider = provider_name
async def request(
self,
method: str,
url: str,
*,
json: Any = None,
headers: Mapping[str, str] | None = None,
ssrf_validate: bool = True,
retry_statuses: frozenset[int] = _RETRY_STATUSES,
) -> dict[str, Any]:
"""Send a single request with retry + redaction. Always returns a dict.
On 2xx returns ``{"success": True, "status_code": int, "json": ...
OR "body": str}``. On non-2xx returns the canonical error dict.
"""
if ssrf_validate:
try:
await avalidate_outbound_url(url)
except UnsafeURLError as err:
return make_error(f"Unsafe URL: {redact_exc(err)}")
outbound_headers: dict[str, str] = {"Content-Type": "application/json"}
outbound_headers.update(safe_headers(headers))
for attempt in range(1, self._max_retries + 1):
try:
async with self._session.request(
method,
url,
json=json,
headers=outbound_headers,
timeout=self._timeout,
allow_redirects=False,
) as resp:
if resp.status in retry_statuses and attempt < self._max_retries:
delay = _retry_after_seconds(resp.headers, self._MAX_RETRY_AFTER)
_LOGGER.warning(
"%s %s %s: HTTP %d, retrying after %.2fs (attempt %d/%d)",
self._provider, method, redact(url), resp.status,
delay, attempt, self._max_retries,
)
await resp.read() # drain body so connection can return to pool
await asyncio.sleep(delay)
continue
if 200 <= resp.status < 300:
try:
payload: Any = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
payload = await resp.text()
return make_success(status_code=resp.status, json=payload)
body = await resp.text()
return make_error(
f"HTTP {resp.status}",
status=resp.status,
body=body,
)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
# asyncio.CancelledError inherits from BaseException on
# 3.8+, so it is not caught here — good: cancellation
# must propagate.
if attempt < self._max_retries and isinstance(err, asyncio.TimeoutError):
_LOGGER.warning(
"%s %s %s: timeout, retrying (attempt %d/%d)",
self._provider, method, redact(url),
attempt, self._max_retries,
)
await asyncio.sleep(min(2 ** (attempt - 1), 5))
continue
return make_error(redact_exc(err))
# Retry budget exhausted on a retriable status.
return make_error("Rate limited (retries exhausted)")
@@ -2,22 +2,36 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import time import re
from typing import Any import uuid
from typing import Any, Final
from urllib.parse import quote
import aiohttp import aiohttp
from ..http_base import _MAX_RETRY_AFTER_S, safe_headers
from ..redact import redact, redact_exc
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Monotonically increasing transaction counter for idempotent sends # Matrix room IDs are ``!opaque:server.name`` per the spec. We also allow
_txn_counter = int(time.time() * 1000) # the ``#alias:server`` form because some callers may pass aliases. The
# pattern's purpose is to reject obvious path-injection (``/``, ``..``,
# control chars, query/fragment chars) before the value reaches a URL.
_ROOM_ID_RE: Final = re.compile(r"^[!#][^\x00-\x1f\s/?#]{1,255}:[A-Za-z0-9.\-:]{1,255}$")
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
_MAX_RETRIES: Final = 3
def _next_txn_id() -> str: def _validate_room_id(room_id: str) -> str:
global _txn_counter if not room_id:
_txn_counter += 1 raise ValueError("room_id is empty")
return str(_txn_counter) if not _ROOM_ID_RE.match(room_id):
raise ValueError("room_id format is invalid")
return room_id
class MatrixClient: class MatrixClient:
@@ -33,49 +47,67 @@ class MatrixClient:
self._homeserver = homeserver_url.rstrip("/") self._homeserver = homeserver_url.rstrip("/")
self._token = access_token self._token = access_token
@staticmethod
def _txn_id() -> str:
# uuid4 hex is collision-resistant across processes/restarts;
# eliminates the previous module-level counter race.
return uuid.uuid4().hex
async def send_message( async def send_message(
self, self,
room_id: str, room_id: str,
message: str, message: str,
html_message: str | None = None, html_message: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send a text message to a Matrix room. """Send a text message to a Matrix room."""
try:
room_id = _validate_room_id(room_id)
except ValueError as exc:
return {"success": False, "error": f"Invalid room_id: {exc}"}
Args: encoded_room = quote(room_id, safe="")
room_id: Internal room ID (e.g. !abc:matrix.org) url = (
message: Plain text body f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}"
html_message: Optional HTML-formatted body f"/send/m.room.message/{self._txn_id()}"
""" )
if not room_id:
return {"success": False, "error": "Missing room_id"}
txn_id = _next_txn_id() body: dict[str, Any] = {"msgtype": "m.text", "body": message}
# URL-encode the room_id (! and : need encoding)
encoded_room = room_id.replace("!", "%21").replace(":", "%3A")
url = f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
body: dict[str, Any] = {
"msgtype": "m.text",
"body": message,
}
if html_message: if html_message:
body["format"] = "org.matrix.custom.html" body["format"] = "org.matrix.custom.html"
body["formatted_body"] = html_message body["formatted_body"] = html_message
headers = { headers = safe_headers({
"Authorization": f"Bearer {self._token}", "Authorization": f"Bearer {self._token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} })
try: for attempt in range(1, _MAX_RETRIES + 1):
async with self._session.put( try:
url, json=body, headers=headers, allow_redirects=False, async with self._session.put(
) as resp: url, json=body, headers=headers,
if 200 <= resp.status < 300: timeout=_DEFAULT_TIMEOUT, allow_redirects=False,
return {"success": True} ) as resp:
resp_body = await resp.text() if 200 <= resp.status < 300:
if resp.status == 429: return {"success": True}
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200]) resp_body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"} if resp.status == 429 and attempt < _MAX_RETRIES:
except aiohttp.ClientError as e: try:
return {"success": False, "error": str(e)} wait_s = float(resp.headers.get("Retry-After", "2"))
except (TypeError, ValueError):
wait_s = 2.0
wait_s = max(0.0, min(wait_s, _MAX_RETRY_AFTER_S))
_LOGGER.warning(
"Matrix rate limited, retrying after %.2fs (attempt %d/%d)",
wait_s, attempt, _MAX_RETRIES,
)
await asyncio.sleep(wait_s)
continue
return {
"success": False,
"error": f"HTTP {resp.status}: {redact(resp_body)[:200]}",
"status_code": resp.status,
}
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
return {"success": False, "error": redact_exc(e)}
return {"success": False, "error": "Rate limited (retries exhausted)"}
@@ -3,18 +3,33 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, Final
import aiohttp import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_PRIORITY_MIN: Final = 1
_PRIORITY_MAX: Final = 5
_DEFAULT_PRIORITY: Final = 3
_MAX_TAGS: Final = 10
_MAX_TAG_LEN: Final = 64
class NtfyClient:
def _strip_crlf(value: str) -> str:
"""Remove CR/LF — ntfy's JSON path is safe today, but the same fields
are used by the header API; defensive sanitization here means a future
refactor can't accidentally re-introduce header injection."""
return value.replace("\r", " ").replace("\n", " ")
class NtfyClient(HttpProviderClient):
"""Sends push notifications via ntfy server.""" """Sends push notifications via ntfy server."""
def __init__(self, session: aiohttp.ClientSession) -> None: def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session super().__init__(session, provider_name="ntfy")
async def send( async def send(
self, self,
@@ -22,41 +37,48 @@ class NtfyClient:
topic: str, topic: str,
message: str, message: str,
title: str | None = None, title: str | None = None,
priority: int = 3, priority: int = _DEFAULT_PRIORITY,
tags: list[str] | None = None, tags: list[str] | None = None,
click_url: str | None = None, click_url: str | None = None,
auth_token: str | None = None, auth_token: str | None = None,
markdown: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send a push notification to an ntfy topic.""" """Send a push notification to an ntfy topic."""
if not server_url or not topic: if not server_url or not topic:
return {"success": False, "error": "Missing server_url or topic"} return {"success": False, "error": "Missing server_url or topic"}
url = f"{server_url.rstrip('/')}" topic = _strip_crlf(topic).strip()
if not topic:
return {"success": False, "error": "Topic is empty after sanitization"}
try:
priority_int = int(priority) if priority is not None else _DEFAULT_PRIORITY
except (TypeError, ValueError):
priority_int = _DEFAULT_PRIORITY
priority_int = max(_PRIORITY_MIN, min(priority_int, _PRIORITY_MAX))
payload: dict[str, Any] = { payload: dict[str, Any] = {
"topic": topic, "topic": topic,
"message": message, "message": message,
"markdown": True, "markdown": bool(markdown),
} }
if title: if title:
payload["title"] = title payload["title"] = _strip_crlf(title)
if priority != 3: if priority_int != _DEFAULT_PRIORITY:
payload["priority"] = priority payload["priority"] = priority_int
if tags: if tags:
payload["tags"] = tags cleaned = [
_strip_crlf(str(t))[:_MAX_TAG_LEN]
for t in tags[:_MAX_TAGS]
if t
]
if cleaned:
payload["tags"] = cleaned
if click_url: if click_url:
payload["click"] = click_url payload["click"] = _strip_crlf(click_url)
headers: dict[str, str] = {"Content-Type": "application/json"} headers: dict[str, str] = {}
if auth_token: if auth_token:
headers["Authorization"] = f"Bearer {auth_token}" headers["Authorization"] = f"Bearer {auth_token}"
try: return await self.request("POST", server_url.rstrip("/"), json=payload, headers=headers)
async with self._session.post(
url, json=payload, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@@ -2,47 +2,88 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import copy
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, Final
from notify_bridge_core.storage import StorageBackend from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Bound on queue length. Without a cap, a misconfigured quiet-hour
# window plus high event throughput grows the persisted file unboundedly
# and every enqueue rewrites the whole file (O(n²) total writes). When
# the cap is hit we drop the oldest entry (FIFO) so the most recent
# events still reach the recipient when the window opens.
DEFAULT_MAX_QUEUE_SIZE: Final = 1000
class NotificationQueue: class NotificationQueue:
"""Persistent queue for notifications deferred during quiet hours.""" """Persistent queue for notifications deferred during quiet hours."""
def __init__(self, backend: StorageBackend) -> None: def __init__(
self,
backend: StorageBackend,
*,
max_size: int = DEFAULT_MAX_QUEUE_SIZE,
) -> None:
self._backend = backend self._backend = backend
self._data: dict[str, Any] | None = None self._data: dict[str, Any] | None = None
self._max_size = max_size
# Coordinates load / enqueue / clear / remove so a write-while-load
# race can't leave the in-memory copy out of sync with disk and so
# bulk operations don't interleave their reads-then-writes.
self._lock = asyncio.Lock()
@staticmethod
def _ensure_schema(data: Any) -> dict[str, Any]:
if not isinstance(data, dict) or not isinstance(data.get("queue"), list):
return {"queue": []}
return data
async def async_load(self) -> None: async def async_load(self) -> None:
self._data = await self._backend.load() or {"queue": []} async with self._lock:
raw = await self._backend.load()
self._data = self._ensure_schema(raw)
async def async_enqueue(self, notification_params: dict[str, Any]) -> None: async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
if self._data is None: async with self._lock:
self._data = {"queue": []} if self._data is None:
self._data["queue"].append({ self._data = {"queue": []}
"params": notification_params, queue: list[dict[str, Any]] = self._data["queue"]
"queued_at": datetime.now(timezone.utc).isoformat(), queue.append({
}) "params": notification_params,
await self._backend.save(self._data) "queued_at": datetime.now(timezone.utc).isoformat(),
})
if self._max_size > 0 and len(queue) > self._max_size:
# Drop oldest (FIFO) so a new event can still land.
drop = len(queue) - self._max_size
_LOGGER.warning(
"NotificationQueue: dropping %d oldest entries (cap=%d)",
drop, self._max_size,
)
del queue[:drop]
await self._backend.save(self._data)
def get_all(self) -> list[dict[str, Any]]: def get_all(self) -> list[dict[str, Any]]:
if not self._data: if not self._data:
return [] return []
return list(self._data.get("queue", [])) # Deep copy so callers can iterate / mutate without corrupting the
# in-memory queue. The cost is bounded by ``max_size``.
return copy.deepcopy(list(self._data.get("queue", [])))
def has_pending(self) -> bool: def has_pending(self) -> bool:
return bool(self._data and self._data.get("queue")) return bool(self._data and self._data.get("queue"))
async def async_clear(self) -> None: async def async_clear(self) -> None:
if self._data: async with self._lock:
self._data["queue"] = [] if self._data:
await self._backend.save(self._data) self._data["queue"] = []
await self._backend.save(self._data)
async def async_remove(self) -> None: async def async_remove(self) -> None:
await self._backend.remove() async with self._lock:
self._data = None await self._backend.remove()
self._data = None
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, Callable
@dataclass @dataclass
@@ -70,51 +70,64 @@ class MatrixReceiver(Receiver):
room_id: str = "" room_id: str = ""
_ReceiverFactory = Callable[[str, dict[str, Any]], Receiver]
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
_RECEIVER_FACTORIES: dict[str, _ReceiverFactory] = {
"telegram": lambda locale, config: TelegramReceiver(
locale=locale, config=config, chat_id=str(config.get("chat_id", "")),
),
"webhook": lambda locale, config: WebhookReceiver(
locale=locale, config=config,
url=str(config.get("url", "")),
headers=dict(config.get("headers", {}) or {}),
),
"email": lambda locale, config: EmailReceiver(
locale=locale, config=config,
email=str(config.get("email", "")),
name=str(config.get("name", "")),
),
"discord": lambda locale, config: DiscordReceiver(
locale=locale, config=config,
webhook_url=str(config.get("webhook_url", "")),
),
"slack": lambda locale, config: SlackReceiver(
locale=locale, config=config,
webhook_url=str(config.get("webhook_url", "")),
),
"ntfy": lambda locale, config: NtfyReceiver(
locale=locale, config=config,
topic=str(config.get("topic", "")),
priority=_coerce_int(config.get("priority"), 3),
),
"matrix": lambda locale, config: MatrixReceiver(
locale=locale, config=config,
room_id=str(config.get("room_id", "")),
),
}
def register_receiver_factory(target_type: str, factory: _ReceiverFactory) -> None:
"""Register a receiver factory for an out-of-tree target type."""
_RECEIVER_FACTORIES[target_type] = factory
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver: def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
"""Factory: build typed Receiver from target type and config dict.""" """Factory: build typed Receiver from target type and config dict.
if target_type == "telegram":
return TelegramReceiver( Falls back to a base ``Receiver`` for unknown target types so callers
locale=locale, that handle types defensively still receive a usable object but the
config=config, dispatcher rejects them with ``"Unknown target type"`` so a typo can't
chat_id=str(config.get("chat_id", "")), silently route to nowhere.
) """
if target_type == "webhook": factory = _RECEIVER_FACTORIES.get(target_type)
return WebhookReceiver( if factory is None:
locale=locale, return Receiver(locale=locale, config=config)
config=config, return factory(locale, config)
url=config.get("url", ""),
headers=config.get("headers", {}),
)
if target_type == "email":
return EmailReceiver(
locale=locale,
config=config,
email=config.get("email", ""),
name=config.get("name", ""),
)
if target_type == "discord":
return DiscordReceiver(
locale=locale,
config=config,
webhook_url=config.get("webhook_url", ""),
)
if target_type == "slack":
return SlackReceiver(
locale=locale,
config=config,
webhook_url=config.get("webhook_url", ""),
)
if target_type == "ntfy":
return NtfyReceiver(
locale=locale,
config=config,
topic=config.get("topic", ""),
priority=config.get("priority", 3),
)
if target_type == "matrix":
return MatrixReceiver(
locale=locale,
config=config,
room_id=config.get("room_id", ""),
)
return Receiver(locale=locale, config=config)
@@ -0,0 +1,64 @@
"""Secret-redaction helpers for log lines and error strings.
Notification clients embed secrets in URLs (Telegram bot tokens) and
Authorization headers (Matrix access tokens, ntfy bearer tokens). When
those secrets surface in ``aiohttp.ClientError.__str__``, response
bodies, or operator-visible error fields, they leak into logs and into
the per-target result dict that callers may forward upstream. ``redact``
returns a defanged copy safe for both contexts.
"""
from __future__ import annotations
import re
from typing import Final
# api.telegram.org/bot<digits>:<token>/<method>
_TELEGRAM_BOT_TOKEN_RE: Final = re.compile(
r"(api\.telegram\.org/bot)\d+:[A-Za-z0-9_-]+", re.IGNORECASE,
)
# Authorization: Bearer <token> (header form, case-insensitive)
_BEARER_RE: Final = re.compile(r"(Bearer\s+)[A-Za-z0-9._\-+/=]+", re.IGNORECASE)
# Discord webhook: /api/webhooks/<id>/<token>
_DISCORD_WEBHOOK_RE: Final = re.compile(
r"(discord(?:app)?\.com/api/webhooks/\d+/)[A-Za-z0-9_-]+",
re.IGNORECASE,
)
# Slack webhook path: /services/T.../B.../<token>
_SLACK_WEBHOOK_RE: Final = re.compile(
r"(hooks\.slack\.com/services/[A-Z0-9]+/[A-Z0-9]+/)[A-Za-z0-9]+",
re.IGNORECASE,
)
# URL userinfo: scheme://user:password@host
_URL_USERINFO_RE: Final = re.compile(
r"([a-z][a-z0-9+\-.]*://)[^/@\s]+:[^/@\s]+@",
re.IGNORECASE,
)
# Common token query parameters
_QUERY_TOKEN_RE: Final = re.compile(
r"([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s]+",
re.IGNORECASE,
)
def redact(text: str) -> str:
"""Return ``text`` with known secret patterns replaced by ``***``.
Idempotent and safe to call on already-redacted strings. Always
returns a ``str``; non-strings are coerced via ``str()`` so callers
can pass exception instances directly.
"""
if not isinstance(text, str):
text = str(text)
text = _TELEGRAM_BOT_TOKEN_RE.sub(r"\1***", text)
text = _DISCORD_WEBHOOK_RE.sub(r"\1***", text)
text = _SLACK_WEBHOOK_RE.sub(r"\1***", text)
text = _BEARER_RE.sub(r"\1***", text)
text = _URL_USERINFO_RE.sub(r"\1***@", text)
text = _QUERY_TOKEN_RE.sub(r"\1***", text)
return text
def redact_exc(err: BaseException) -> str:
"""Redact-and-stringify an exception. Convenience for error fields."""
return redact(str(err))
@@ -7,14 +7,16 @@ from typing import Any
import aiohttp import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class SlackClient: class SlackClient(HttpProviderClient):
"""Sends messages via Slack incoming webhook URLs.""" """Sends messages via Slack incoming webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None: def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session super().__init__(session, provider_name="slack")
async def send( async def send(
self, self,
@@ -33,19 +35,4 @@ class SlackClient:
if icon_emoji: if icon_emoji:
payload["icon_emoji"] = icon_emoji payload["icon_emoji"] = icon_emoji
try: return await self.request("POST", webhook_url, json=payload)
async with self._session.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
allow_redirects=False,
) as resp:
if resp.status == 429:
_LOGGER.warning("Slack rate limited")
return {"success": False, "error": "Rate limited by Slack"}
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@@ -1,10 +1,22 @@
"""Outbound URL validation to mitigate SSRF attacks. """Outbound URL validation to mitigate SSRF attacks.
User-controlled URLs (provider `url`, webhook target `url`, shared-link User-controlled URLs (provider ``url``, webhook target ``url``,
base URLs, image downloads) must be validated before any HTTP request is shared-link base URLs, image downloads) must be validated before any
issued. This module rejects schemes other than http/https and blocks HTTP request is issued. This module rejects schemes other than
destinations that resolve to private, loopback, link-local, or unspecified http/https and blocks destinations that resolve to private, loopback,
address ranges. link-local, unspecified, CGNAT (100.64.0.0/10), or IPv4-mapped IPv6
ranges.
DNS rebinding mitigation
~~~~~~~~~~~~~~~~~~~~~~~~
``avalidate_outbound_url`` returns the original URL on success, but
also returns the resolved IP it actually validated. Callers that pass
the validated URL straight into ``aiohttp`` are vulnerable to a
DNS-rebinding attack: the validator's ``getaddrinfo`` returns a public
IP; aiohttp's connect-time resolution returns ``127.0.0.1``. To close
that gap, use :func:`build_ssrf_safe_session` (or
:class:`PinnedResolver`) so the resolved IP from the validation step is
the one aiohttp connects to.
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
development against localhost services. development against localhost services.
@@ -17,12 +29,20 @@ import ipaddress
import logging import logging
import os import os
import socket import socket
from dataclasses import dataclass
from urllib.parse import urlparse from urllib.parse import urlparse
import aiohttp
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1" _ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
_ALLOWED_SCHEMES = {"http", "https"} _ALLOWED_SCHEMES = frozenset({"http", "https"})
# Carrier-grade NAT range. Not in stdlib's ``is_private``; an attacker
# pointing a domain at a CGNAT IP could reach the operator's ISP-side
# routing infrastructure. RFC 6598.
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
_LOGGER.warning( _LOGGER.warning(
@@ -36,7 +56,29 @@ class UnsafeURLError(ValueError):
"""Raised when a URL targets a disallowed network destination.""" """Raised when a URL targets a disallowed network destination."""
@dataclass(frozen=True)
class ValidatedURL:
"""Result of validating an outbound URL.
Attributes:
url: The original URL string (unchanged).
host: Hostname extracted from the URL (lower-cased, IDN-encoded).
ip: Resolved IP address that passed the block-range check, as a
string. Pass to :class:`PinnedResolver` to defeat DNS
rebinding by reusing this exact IP at connect time.
"""
url: str
host: str
ip: str
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
# An IPv4-mapped IPv6 like ``::ffff:127.0.0.1`` is NOT considered
# ``is_private`` etc. by stdlib — the v4 view holds those flags. So
# we unwrap before checking.
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return ( return (
ip.is_private ip.is_private
or ip.is_loopback or ip.is_loopback
@@ -44,22 +86,54 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
or ip.is_multicast or ip.is_multicast
or ip.is_reserved or ip.is_reserved
or ip.is_unspecified or ip.is_unspecified
or (isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK)
) )
def _safe_host_repr(host: str) -> str:
"""Return ``host`` shortened/escaped for safe inclusion in error text."""
h = host[:64].replace("\r", "").replace("\n", "")
return h
def _normalize_host(parsed_host: str) -> str:
"""Normalize a hostname: lowercase, strip trailing dot, IDN-encode."""
host = parsed_host.lower()
if host.endswith("."):
host = host[:-1]
# Strip IPv6 zone id ("fe80::1%eth0") — must not reach the resolver.
if "%" in host:
host = host.split("%", 1)[0]
# IDN-encode unicode hostnames so we don't downgrade to confusables
# in any later log/output and so getaddrinfo gets the ascii form.
try:
if any(ord(c) > 127 for c in host):
host = host.encode("idna").decode("ascii")
except UnicodeError:
# Caller will fail on resolution; leave as-is so the error path
# surfaces a "DNS resolution failed" rather than a stack trace.
pass
return host
def _check_scheme_host(url: str) -> tuple[str, str]: def _check_scheme_host(url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url: if not isinstance(url, str) or not url:
raise UnsafeURLError("URL is empty") raise UnsafeURLError("URL is empty")
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme not in _ALLOWED_SCHEMES: scheme = parsed.scheme.lower()
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed") if scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{scheme[:16]}' not allowed")
host = parsed.hostname host = parsed.hostname
if not host: if not host:
raise UnsafeURLError("URL has no host") raise UnsafeURLError("URL has no host")
return parsed.scheme, host return scheme, _normalize_host(host)
def _check_resolved_addresses(host: str, infos: list[tuple]) -> None: def _select_addresses(
host: str, infos: list[tuple],
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
"""Return parsed, non-blocked IPs from ``getaddrinfo`` results."""
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos: for info in infos:
sockaddr = info[4] sockaddr = info[4]
try: try:
@@ -67,64 +141,143 @@ def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
except ValueError: except ValueError:
continue continue
if _is_blocked_ip(ip): if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}") raise UnsafeURLError(
f"Host {_safe_host_repr(host)} resolves to blocked address {ip}"
)
addrs.append(ip)
if not addrs:
raise UnsafeURLError(f"Host {_safe_host_repr(host)} has no usable address")
return addrs
def validate_outbound_url(url: str) -> str: def validate_outbound_url(url: str) -> str:
"""Validate ``url`` is safe to fetch; returns the URL on success. """Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP .. deprecated::
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``) Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
private addresses are permitted but the scheme check still applies. :func:`avalidate_outbound_url` from async code paths so the
event loop isn't blocked, and use :func:`build_ssrf_safe_session`
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer to defeat DNS rebinding.
:func:`avalidate_outbound_url` from async code paths.
""" """
_, host = _check_scheme_host(url) _, host = _check_scheme_host(url)
if _ALLOW_PRIVATE: if _ALLOW_PRIVATE:
return url return url
# Literal IP host
try: try:
ip = ipaddress.ip_address(host) ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip): if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range") raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
return url return url
except ValueError: except ValueError:
pass pass
try: try:
infos = socket.getaddrinfo(host, None) infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc: except (socket.gaierror, UnicodeError, OSError) as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc # ``UnicodeError`` covers IDNA failures (labels >63 chars, malformed
_check_resolved_addresses(host, infos) # unicode) which getaddrinfo surfaces as encoding errors rather than
# gaierror. ``OSError`` covers transient resolver failures on some
# platforms.
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
_select_addresses(host, infos)
return url return url
async def avalidate_outbound_url(url: str) -> str: async def avalidate_outbound_url(url: str) -> str:
"""Async variant that resolves DNS via the running loop's resolver. """Async variant — returns the URL on success.
Use this from ``async def`` code paths to avoid blocking the event For DNS-rebinding-safe usage, prefer :func:`avalidate_outbound_url_full`
loop on DNS lookups. which also returns the resolved IP for connect-time pinning.
"""
result = await avalidate_outbound_url_full(url)
return result.url
async def avalidate_outbound_url_full(url: str) -> ValidatedURL:
"""Validate ``url`` and return a :class:`ValidatedURL` on success.
The returned ``ip`` field is the IP that passed the block-range
check. Pair with :class:`PinnedResolver` so aiohttp connects to that
exact IP and a malicious DNS server can't swap in a private address
after validation.
""" """
_, host = _check_scheme_host(url) _, host = _check_scheme_host(url)
if _ALLOW_PRIVATE: if _ALLOW_PRIVATE:
return url # In dev mode we still resolve to give a usable IP, but we don't
# gate on the result.
try:
ip = str(ipaddress.ip_address(host))
except ValueError:
try:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host, None)
ip = infos[0][4][0] if infos else host
except (socket.gaierror, OSError):
ip = host
return ValidatedURL(url=url, host=host, ip=ip)
# Literal IP host
try: try:
ip = ipaddress.ip_address(host) ip_obj = ipaddress.ip_address(host)
if _is_blocked_ip(ip): if _is_blocked_ip(ip_obj):
raise UnsafeURLError(f"Host {host} is in a blocked range") raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
return url return ValidatedURL(url=url, host=host, ip=str(ip_obj))
except ValueError: except ValueError:
pass pass
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
infos = await loop.getaddrinfo(host, None) infos = await loop.getaddrinfo(host, None)
except socket.gaierror as exc: except (socket.gaierror, UnicodeError, OSError) as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
_check_resolved_addresses(host, infos) addrs = _select_addresses(host, infos)
return url return ValidatedURL(url=url, host=host, ip=str(addrs[0]))
class PinnedResolver(aiohttp.abc.AbstractResolver):
"""aiohttp resolver that returns a fixed (host, ip) mapping.
Used to pin the resolved IP from :func:`avalidate_outbound_url_full`
so aiohttp's connect-time resolution can't be tricked by DNS
rebinding into using a different IP than the one we validated.
Falls back to :class:`aiohttp.AsyncResolver` (or default) for any
host not explicitly pinned, so a single resolver instance can be
reused across multiple validated URLs.
"""
def __init__(self, mapping: dict[str, str] | None = None) -> None:
self._map: dict[str, str] = dict(mapping or {})
self._fallback: aiohttp.abc.AbstractResolver | None = None
def pin(self, host: str, ip: str) -> None:
self._map[host.lower()] = ip
async def resolve(
self, host: str, port: int = 0, family: int = socket.AF_INET,
) -> list[dict]:
ip = self._map.get(host.lower())
if ip is not None:
try:
ip_obj = ipaddress.ip_address(ip)
except ValueError:
ip_obj = None
if ip_obj is not None:
fam = socket.AF_INET6 if ip_obj.version == 6 else socket.AF_INET
return [{
"hostname": host,
"host": ip,
"port": port,
"family": fam,
"proto": 0,
"flags": socket.AI_NUMERICHOST,
}]
if self._fallback is None:
self._fallback = aiohttp.ThreadedResolver()
return await self._fallback.resolve(host, port, family)
async def close(self) -> None:
if self._fallback is not None:
await self._fallback.close()
@@ -2,16 +2,29 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, Final
from notify_bridge_core.storage import StorageBackend from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60 DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000 DEFAULT_MAX_ENTRIES: Final = 5000
def _parse_iso(value: str | None) -> datetime | None:
"""Parse an ISO-8601 timestamp tolerantly. Returns ``None`` on failure."""
if not value or not isinstance(value, str):
return None
try:
# Python <3.11 doesn't accept "Z"; normalize to +00:00.
v = value.replace("Z", "+00:00") if value.endswith("Z") else value
return datetime.fromisoformat(v)
except ValueError:
return None
class TelegramFileCache: class TelegramFileCache:
@@ -25,7 +38,17 @@ class TelegramFileCache:
Intended for content-addressable assets (e.g. Immich) where re-uploads Intended for content-addressable assets (e.g. Immich) where re-uploads
should be triggered by visual change, not elapsed time. should be triggered by visual change, not elapsed time.
``max_entries`` always applies as an LRU size cap (by ``cached_at``). ``max_entries`` always applies as a FIFO size cap (oldest-cached first).
Concurrency
~~~~~~~~~~~
All mutators take an internal ``asyncio.Lock`` so concurrent
media-group sends can't interleave a read-time invalidation with a
bulk write and corrupt the underlying dict (``RuntimeError:
dictionary changed size during iteration``) or lose just-written
entries. Reads do not take the lock they are O(1) dict lookups
but ``get`` uses a snapshot reference so it cannot mutate the data
structure under another task.
""" """
def __init__( def __init__(
@@ -40,35 +63,40 @@ class TelegramFileCache:
self._ttl_seconds = ttl_seconds self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash self._use_thumbhash = use_thumbhash
self._max_entries = max_entries self._max_entries = max_entries
self._lock = asyncio.Lock()
async def async_load(self) -> None: async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}} async with self._lock:
await self._cleanup_expired() self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired_locked()
async def _cleanup_expired(self) -> None: async def _cleanup_expired_locked(self) -> None:
"""Caller must hold ``self._lock``."""
if not self._data or "files" not in self._data: if not self._data or "files" not in self._data:
return return
files = self._data["files"] files: dict[str, dict[str, Any]] = self._data["files"]
changed = False changed = False
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
# mode and a positive TTL). In thumbhash mode we rely entirely on
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
# cache forever, subject only to the size cap.
if not self._use_thumbhash and self._ttl_seconds > 0: if not self._use_thumbhash and self._ttl_seconds > 0:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expired = [ expired: list[str] = []
url for url, entry in files.items() for url, entry in list(files.items()):
if entry.get("cached_at") and cached_at = _parse_iso(entry.get("cached_at"))
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds if cached_at is None:
] continue
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
if (now - cached_at).total_seconds() > self._ttl_seconds:
expired.append(url)
for key in expired: for key in expired:
del files[key] del files[key]
changed = True changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
if self._max_entries > 0 and len(files) > self._max_entries: if self._max_entries > 0 and len(files) > self._max_entries:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", "")) sorted_keys = sorted(
files,
key=lambda k: _parse_iso(files[k].get("cached_at")) or datetime.min.replace(tzinfo=timezone.utc),
)
for key in sorted_keys[: len(files) - self._max_entries]: for key in sorted_keys[: len(files) - self._max_entries]:
del files[key] del files[key]
changed = True changed = True
@@ -80,7 +108,10 @@ class TelegramFileCache:
if not self._data or "files" not in self._data: if not self._data or "files" not in self._data:
return None return None
entry = self._data["files"].get(key) # Take a local reference so a concurrent ``async_set`` rebuilding
# the dict cannot pull the rug out mid-read.
files = self._data["files"]
entry = files.get(key)
if not entry: if not entry:
return None return None
@@ -88,19 +119,23 @@ class TelegramFileCache:
if thumbhash is not None: if thumbhash is not None:
stored = entry.get("thumbhash") stored = entry.get("thumbhash")
if stored and stored != thumbhash: if stored and stored != thumbhash:
del self._data["files"][key] # Mark stale — actual deletion happens lock-protected
# in the next mutation. Returning None is sufficient
# for the caller to skip the cache hit.
return None return None
elif self._ttl_seconds > 0: elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at") cached_at = _parse_iso(entry.get("cached_at"))
if cached_at_str: if cached_at is not None:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds() if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age > self._ttl_seconds: if age > self._ttl_seconds:
return None return None
return { return {
"file_id": entry.get("file_id"), "file_id": entry.get("file_id"),
"type": entry.get("type"), "type": entry.get("type"),
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries "size": entry.get("size"),
} }
async def async_set( async def async_set(
@@ -111,21 +146,22 @@ class TelegramFileCache:
thumbhash: str | None = None, thumbhash: str | None = None,
size: int | None = None, size: int | None = None,
) -> None: ) -> None:
if self._data is None: async with self._lock:
self._data = {"files": {}} if self._data is None:
self._data = {"files": {}}
entry: dict[str, Any] = { entry: dict[str, Any] = {
"file_id": file_id, "file_id": file_id,
"type": media_type, "type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(), "cached_at": datetime.now(timezone.utc).isoformat(),
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None: if size is not None:
entry["size"] = size entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
async def async_set_many( async def async_set_many(
self, self,
@@ -139,32 +175,34 @@ class TelegramFileCache:
""" """
if not entries: if not entries:
return return
if self._data is None: async with self._lock:
self._data = {"files": {}} if self._data is None:
self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat() now_iso = datetime.now(timezone.utc).isoformat()
for item in entries: for item in entries:
if len(item) == 5: if len(item) == 5:
key, file_id, media_type, thumbhash, size = item key, file_id, media_type, thumbhash, size = item
else: else:
key, file_id, media_type, thumbhash = item key, file_id, media_type, thumbhash = item
size = None size = None
entry: dict[str, Any] = { entry: dict[str, Any] = {
"file_id": file_id, "file_id": file_id,
"type": media_type, "type": media_type,
"cached_at": now_iso, "cached_at": now_iso,
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None: if size is not None:
entry["size"] = size entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
async def async_remove(self) -> None: async def async_remove(self) -> None:
await self._backend.remove() async with self._lock:
self._data = None await self._backend.remove()
self._data = None
def stats(self) -> dict[str, Any]: def stats(self) -> dict[str, Any]:
"""Return summary stats about the current cache contents. """Return summary stats about the current cache contents.
@@ -172,25 +210,33 @@ class TelegramFileCache:
Includes the number of cached entries, total tracked size in bytes Includes the number of cached entries, total tracked size in bytes
(only counts entries with a recorded ``size``), and the oldest / (only counts entries with a recorded ``size``), and the oldest /
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty). newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
Timestamps are compared as parsed ``datetime`` objects so mixed
timezone formats (``Z`` vs ``+00:00``) order correctly.
""" """
files = self._data.get("files", {}) if self._data else {} files = self._data.get("files", {}) if self._data else {}
count = len(files) count = len(files)
total_size = 0 total_size = 0
oldest: str | None = None oldest_dt: datetime | None = None
newest: str | None = None newest_dt: datetime | None = None
oldest_str: str | None = None
newest_str: str | None = None
for entry in files.values(): for entry in files.values():
size = entry.get("size") size = entry.get("size")
if isinstance(size, int): if isinstance(size, int):
total_size += size total_size += size
cached_at = entry.get("cached_at") cached_at = entry.get("cached_at")
if cached_at: dt = _parse_iso(cached_at)
if oldest is None or cached_at < oldest: if dt is None or not cached_at:
oldest = cached_at continue
if newest is None or cached_at > newest: if dt.tzinfo is None:
newest = cached_at dt = dt.replace(tzinfo=timezone.utc)
if oldest_dt is None or dt < oldest_dt:
oldest_dt, oldest_str = dt, cached_at
if newest_dt is None or dt > newest_dt:
newest_dt, newest_str = dt, cached_at
return { return {
"count": count, "count": count,
"total_size_bytes": total_size, "total_size_bytes": total_size,
"oldest": oldest, "oldest": oldest_str,
"newest": newest, "newest": newest_str,
} }
File diff suppressed because it is too large Load Diff
@@ -2,20 +2,35 @@
from __future__ import annotations from __future__ import annotations
import logging
import re import re
from typing import Any, Final from typing import Any, Final
from urllib.parse import urlparse from urllib.parse import urlparse
_LOGGER = logging.getLogger(__name__)
# Telegram constants # Telegram constants
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot" TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000 TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
# Telegram message-text limit (sendMessage) and caption limit
# (sendPhoto/sendVideo/sendDocument/first item of sendMediaGroup).
TELEGRAM_MAX_TEXT_LENGTH: Final = 4096
TELEGRAM_MAX_CAPTION_LENGTH: Final = 1024
# Generic UUID pattern for asset IDs # Strict canonical-UUID pattern (8-4-4-4-12) for asset IDs. The previous
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$") # loose ``[a-f0-9-]{36}`` matched 36 hyphens / arbitrary digit groupings,
# which could collide across providers when used as a cache key.
_ASSET_ID_PATTERN = re.compile(
r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
re.IGNORECASE,
)
# Cache key: "host:uuid" or bare "uuid" # Cache key: "host:uuid" or bare "uuid"
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$") _ASSET_CACHE_KEY_PATTERN = re.compile(
r"^(?:[^:]+:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
re.IGNORECASE,
)
# URL patterns to extract asset IDs (generic enough for Immich-style URLs) # URL patterns to extract asset IDs (generic enough for Immich-style URLs)
_ASSET_ID_URL_PATTERNS = [ _ASSET_ID_URL_PATTERNS = [
@@ -162,5 +177,10 @@ def check_photo_limits(
return False, None, width, height return False, None, width, height
except ImportError: except ImportError:
return False, None, None, None return False, None, None, None
except Exception: except (OSError, ValueError, MemoryError) as exc:
# PIL surfaces ``UnidentifiedImageError`` (subclass of OSError),
# truncated-image / decompression-bomb errors here. Log so a
# corrupt asset isn't silently passed to Telegram and rejected
# downstream with a less actionable error.
_LOGGER.warning("check_photo_limits: failed to inspect image (%d bytes): %s", len(data), exc)
return False, None, None, None return False, None, None, None
@@ -7,37 +7,29 @@ from typing import Any
import aiohttp import aiohttp
from ..ssrf import UnsafeURLError, avalidate_outbound_url from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
class WebhookClient(HttpProviderClient):
"""Send JSON payloads to a webhook URL.
class WebhookClient: The URL is SSRF-validated on every send (defense-in-depth: re-validating
"""Send JSON payloads to a webhook URL.""" catches DNS rebinding between calls and a misconfigured target). Headers
pass through :func:`safe_headers` so a target config can't inject
framing/hop-by-hop headers like ``Host`` or ``Transfer-Encoding``.
"""
def __init__(self, session: aiohttp.ClientSession, url: str, headers: dict[str, str] | None = None) -> None: def __init__(
self._session = session self,
session: aiohttp.ClientSession,
url: str,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(session, provider_name="webhook")
self._url = url self._url = url
self._headers = headers or {} self._extra_headers = headers or {}
async def send(self, payload: dict[str, Any]) -> dict[str, Any]: async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
try: return await self.request("POST", self._url, json=payload, headers=self._extra_headers)
await avalidate_outbound_url(self._url)
except UnsafeURLError as err:
return {"success": False, "error": f"Unsafe URL: {err}"}
try:
async with self._session.post(
self._url,
json=payload,
headers={"Content-Type": "application/json", **self._headers},
timeout=_DEFAULT_TIMEOUT,
allow_redirects=False,
) as response:
if 200 <= response.status < 300:
return {"success": True, "status_code": response.status}
body = await response.text()
return {"success": False, "error": f"HTTP {response.status}", "body": body[:200]}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.6.5" version = "0.7.2"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -218,6 +218,19 @@ async def get_supported_locales(
return locales or ["en"] return locales or ["en"]
@router.get("/external-url")
async def get_external_url(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Return the configured external base URL (available to all users).
Used by the UI to render absolute provider webhook URLs. Returns empty
string when unset so the UI falls back to the relative path.
"""
return {"external_url": (await get_setting(session, "external_url")).rstrip("/")}
async def _reregister_webhooks( async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str session: AsyncSession, base_url: str, secret: str
) -> None: ) -> None:
@@ -403,7 +403,13 @@ async def get_command_variables(
webhook = { webhook = {
"status": { "status": {
"description": "/status webhook provider summary", "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() )).all()
action_name_map = {aid: aname for aid, aname in action_rows} 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: def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map: if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id] return tracker_name_map[e.tracker_id]
@@ -135,11 +160,30 @@ async def get_status(
return f"(deleted) {e.action_name}" return f"(deleted) {e.action_name}"
return "" 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: def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row. """The primary label shown on the event row.
For action events the ``collection_name`` stores the action name; For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show. 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_"): if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name return _display_action_name(e) or e.collection_name
@@ -155,9 +199,14 @@ async def get_status(
"id": e.id, "id": e.id,
"event_type": e.event_type, "event_type": e.event_type,
"collection_name": _display_subject(e), "collection_name": _display_subject(e),
"tracker_id": e.tracker_id,
"tracker_name": _display_tracker_name(e), "tracker_name": _display_tracker_name(e),
"action_id": e.action_id, "action_id": e.action_id,
"action_name": _display_action_name(e), "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_name": _display_provider_name(e),
"provider_id": e.provider_id, "provider_id": e.provider_id,
"assets_count": e.assets_count or 0, "assets_count": e.assets_count or 0,
@@ -33,11 +33,13 @@ def _auto_register() -> None:
from .gitea_handler import GiteaCommandHandler from .gitea_handler import GiteaCommandHandler
from .planka_handler import PlankaCommandHandler from .planka_handler import PlankaCommandHandler
from .nut_handler import NutCommandHandler from .nut_handler import NutCommandHandler
from .webhook_handler import WebhookCommandHandler
register_handler(ImmichCommandHandler()) register_handler(ImmichCommandHandler())
register_handler(GiteaCommandHandler()) register_handler(GiteaCommandHandler())
register_handler(PlankaCommandHandler()) register_handler(PlankaCommandHandler())
register_handler(NutCommandHandler()) register_handler(NutCommandHandler())
register_handler(WebhookCommandHandler())
# Auto-register on import # Auto-register on import
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
return sorted(enabled), merged_limits 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 # Main dispatcher
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -271,12 +366,18 @@ async def handle_command(
chat_id: str, chat_id: str,
text: str, text: str,
language_code: str = "", language_code: str = "",
*,
issuer: dict[str, Any] | None = None,
) -> list[CommandResponse] | None: ) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers. """Handle a bot command. Routes to provider-specific handlers.
Returns a list of CommandResponse objects (one per tracker), or None. Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list. Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config. 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) cmd, args, count_override = parse_command(text)
if not cmd: if not cmd:
@@ -292,10 +393,20 @@ async def handle_command(
# Merged templates for universal commands # Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id) merged_templates = _merge_all_templates(templates_by_config_id)
# Universal commands have no tracker/provider context.
if cmd == "start": if cmd == "start":
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name}) 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": if cmd not in enabled and cmd != "start":
return None return None
@@ -307,13 +418,26 @@ async def handle_command(
cmd, bot.id, chat_id, wait, cmd, bot.id, chat_id, wait,
) )
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": 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 # Universal commands — single merged response
if cmd == "help": if cmd == "help":
ctx = _cmd_help(enabled, locale, merged_templates) ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx) 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 # Provider-specific dispatch — per-tracker
from .dispatch import get_handler from .dispatch import get_handler
@@ -329,48 +453,69 @@ async def handle_command(
from .command_utils import resolve_chat_album_scope from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = [] responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples: dispatched_ctx: list[
if len(responses) >= _MAX_RESPONSES_PER_COMMAND: tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
_LOGGER.warning( ] = []
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)", try:
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples), 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 if result is not None:
responses.append(result)
handler = get_handler(provider.type) dispatched_ctx.append((tracker, config, provider, listener))
if not handler or cmd not in handler.get_provider_commands(): except Exception as exc: # noqa: BLE001 — log then re-raise
continue await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
tracker_templates = _templates_for_config(templates_by_config_id, config) event_type="command_failed", responses=responses,
count = min(count_override or config.default_count or 5, 20) ctx_tuples=ctx_tuples,
response_mode = config.response_mode or "media" extra_details={"error": f"{type(exc).__name__}: {exc}"},
issuer=issuer,
# 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: raise
responses.append(result)
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( def _cmd_help(
@@ -120,7 +120,11 @@ async def telegram_webhook(
async with telegram_chat_action( async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text), 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: if not responses:
_LOGGER.info( _LOGGER.info(
"Command produced no response (cmd=%r) after %.0f ms", "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"), ("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_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 ''"), ("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): if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql)) 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_user_id", "user_id"),
("ix_event_log_action_id", "action_id"), ("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_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( await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})") 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, default=None, foreign_key="action.id", index=True,
) )
action_name: str = Field(default="") 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_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="") provider_name: str = Field(default="")
event_type: str = Field(index=True) 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 # Copy attributes before session closes to avoid detached-instance errors
from types import SimpleNamespace from types import SimpleNamespace
bot_token = bot.token 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) 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( async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text), 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: if not responses:
_LOGGER.info( _LOGGER.info(
"Command produced no response (cmd=%r, poll) after %.0f ms", "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,46 @@
"""Dispatcher result aggregation: per-receiver detail must survive."""
from __future__ import annotations
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher
def test_aggregate_all_success() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": True, "message_id": 1},
{"success": True, "message_id": 2},
])
assert out["success"] is True
assert out["receivers"] == 2
assert out["successes"] == 2
assert out["failures"] == 0
def test_aggregate_partial() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": True},
{"success": False, "error": "boom"},
])
assert out["success"] is True # at least one succeeded
assert out["successes"] == 1
assert out["failures"] == 1
assert "boom" in out["errors"]
assert "results" in out
def test_aggregate_all_fail_preserves_all_errors() -> None:
out = NotificationDispatcher._aggregate_results([
{"success": False, "error": "first"},
{"success": False, "error": "second"},
])
assert out["success"] is False
assert out["error"] == "first" # back-compat top-level field
assert out["errors"] == ["first", "second"]
# Per-receiver details survive — operator can see exactly what failed.
assert len(out["results"]) == 2
def test_aggregate_empty() -> None:
out = NotificationDispatcher._aggregate_results([])
assert out["success"] is False
assert "error" in out
@@ -0,0 +1,77 @@
"""Email client header-injection / address-validation regression tests."""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.email.client import (
EmailClient,
SmtpConfig,
_strip_header,
_validate_email,
_to_html,
)
def test_strip_header_removes_crlf() -> None:
out = _strip_header("Subject\r\nBcc: attacker@example.com")
assert "\r" not in out
assert "\n" not in out
# The injected "Bcc:" line is folded to a single header line; the SMTP
# server will treat it as part of the subject text, not a header.
assert "Bcc:" in out # value preserved as plain text
def test_strip_header_removes_bare_lf() -> None:
out = _strip_header("Hello\nWorld")
assert "\n" not in out
def test_strip_header_handles_non_string() -> None:
assert _strip_header(None) == ""
def test_validate_email_rejects_crlf() -> None:
with pytest.raises(ValueError):
_validate_email("user@example.com\r\nBcc: x@y")
def test_validate_email_rejects_no_at() -> None:
with pytest.raises(ValueError):
_validate_email("not-an-email")
def test_validate_email_rejects_empty() -> None:
with pytest.raises(ValueError):
_validate_email("")
def test_validate_email_accepts_normal() -> None:
assert _validate_email("user@example.com") == "user@example.com"
def test_to_html_escapes_brackets() -> None:
out = _to_html("<script>alert(1)</script>")
assert "<script>" not in out
assert "&lt;script&gt;" in out
@pytest.mark.asyncio
async def test_send_returns_error_on_invalid_to() -> None:
cfg = SmtpConfig(host="smtp.example.com", from_address="from@example.com")
client = EmailClient(cfg)
result = await client.send(
to_email="user@example.com\r\nBcc: attacker@example.com",
subject="hi",
body_text="body",
)
assert result["success"] is False
assert "Invalid email" in result["error"]
@pytest.mark.asyncio
async def test_send_returns_error_on_no_host() -> None:
cfg = SmtpConfig(host="", from_address="from@example.com")
client = EmailClient(cfg)
result = await client.send("u@x.com", "s", "b")
assert result["success"] is False
+53
View File
@@ -0,0 +1,53 @@
"""HttpProviderClient + safe_headers tests."""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.http_base import safe_headers
class TestSafeHeaders:
def test_drops_hop_by_hop(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"Host": "evil.example.com",
"Content-Length": "999",
"Transfer-Encoding": "chunked",
"Connection": "close",
})
assert out == {"X-Custom": "ok"}
def test_rejects_crlf_in_value(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"X-Bad": "value\r\nInjected: yes",
})
assert "X-Custom" in out
assert "X-Bad" not in out
def test_rejects_crlf_in_name(self) -> None:
out = safe_headers({
"X-Custom": "ok",
"X-Bad\r\nInject": "value",
})
assert out == {"X-Custom": "ok"}
def test_empty_input(self) -> None:
assert safe_headers(None) == {}
assert safe_headers({}) == {}
@pytest.mark.asyncio
async def test_http_base_returns_safe_error_on_invalid_url() -> None:
"""An obviously-bad URL must not panic or leak the URL verbatim."""
import aiohttp
from notify_bridge_core.notifications.http_base import HttpProviderClient
async with aiohttp.ClientSession() as sess:
client = HttpProviderClient(sess, provider_name="test")
# file:// is rejected by the SSRF guard before any HTTP call.
result = await client.request("POST", "file:///etc/passwd", json={})
assert result["success"] is False
assert "Unsafe URL" in result["error"]
@@ -0,0 +1,84 @@
"""Matrix client validation: room_id format and quoting."""
from __future__ import annotations
import aiohttp
import pytest
from aioresponses import aioresponses
from notify_bridge_core.notifications.matrix.client import MatrixClient
HOMESERVER = "https://matrix.example.com"
TOKEN = "secret-bearer-token-1234567890"
@pytest.mark.asyncio
async def test_rejects_path_injection_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host/../../etc/passwd", "hi")
assert result["success"] is False
assert "room_id" in result["error"]
@pytest.mark.asyncio
async def test_rejects_empty_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("", "hi")
assert result["success"] is False
assert "room_id" in result["error"]
@pytest.mark.asyncio
async def test_rejects_unicode_control_chars_in_room_id() -> None:
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc\x00:host", "hi")
assert result["success"] is False
@pytest.mark.asyncio
async def test_url_encodes_room_id_special_chars() -> None:
"""``!`` and ``:`` must reach the server URL-encoded."""
captured: list[str] = []
with aioresponses() as mocked:
# Match any PUT under the rooms path; capture the URL we got.
mocked.put(
"https://matrix.example.com/_matrix/client/v3/rooms/%21abc%3Ahost.example/send/m.room.message",
status=200, body='{}', repeat=True,
)
# aioresponses doesn't expose URL templates well, so use a regex mock.
import re
mocked.put(
re.compile(r"https://matrix\.example\.com/_matrix/client/v3/rooms/[^/]+/send/m\.room\.message/.*"),
status=200, body='{}', repeat=True,
)
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host.example", "hi")
assert result["success"] is True
@pytest.mark.asyncio
async def test_redacts_bearer_in_error() -> None:
"""A 4xx response body must not echo the Authorization Bearer back to caller."""
import re
with aioresponses() as mocked:
mocked.put(
re.compile(r".*"),
status=403,
body='{"errcode": "M_FORBIDDEN", "Authorization": "Bearer ' + TOKEN + '"}',
repeat=True,
)
async with aiohttp.ClientSession() as sess:
client = MatrixClient(sess, HOMESERVER, TOKEN)
result = await client.send_message("!abc:host.example", "hi")
assert result["success"] is False
assert TOKEN not in result["error"]
+84
View File
@@ -0,0 +1,84 @@
"""NotificationQueue bound + concurrency regression tests."""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from notify_bridge_core.notifications.queue import (
DEFAULT_MAX_QUEUE_SIZE,
NotificationQueue,
)
class _MemBackend:
"""In-memory storage backend stub for tests."""
def __init__(self) -> None:
self._data: dict[str, Any] | None = None
async def load(self) -> dict[str, Any] | None:
return self._data
async def save(self, data: dict[str, Any]) -> None:
self._data = data
async def remove(self) -> None:
self._data = None
@pytest.mark.asyncio
async def test_load_with_garbage_falls_back_to_empty() -> None:
backend = _MemBackend()
backend._data = {"queue": "not-a-list"} # type: ignore[assignment]
q = NotificationQueue(backend)
await q.async_load()
assert q.get_all() == []
@pytest.mark.asyncio
async def test_enqueue_caps_at_max_size() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=3)
await q.async_load()
for i in range(10):
await q.async_enqueue({"i": i})
items = q.get_all()
assert len(items) == 3
# FIFO drop: most recent three are kept (i=7..9).
assert [it["params"]["i"] for it in items] == [7, 8, 9]
@pytest.mark.asyncio
async def test_get_all_returns_deep_copy() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=10)
await q.async_load()
await q.async_enqueue({"key": "value"})
snap = q.get_all()
snap[0]["params"]["key"] = "MUTATED"
snap2 = q.get_all()
assert snap2[0]["params"]["key"] == "value"
@pytest.mark.asyncio
async def test_concurrent_enqueue_and_clear_no_corruption() -> None:
backend = _MemBackend()
q = NotificationQueue(backend, max_size=DEFAULT_MAX_QUEUE_SIZE)
await q.async_load()
async def producer() -> None:
for i in range(50):
await q.async_enqueue({"i": i})
async def clearer() -> None:
for _ in range(10):
await asyncio.sleep(0)
await q.async_clear()
await asyncio.gather(producer(), clearer())
# No exceptions = no race-induced "dictionary changed size during iteration".
items = q.get_all()
assert isinstance(items, list)
+74
View File
@@ -0,0 +1,74 @@
"""Secret-redaction helper regression tests.
Locks in the patterns that surface from real provider error paths:
Telegram bot URLs in aiohttp.ClientError messages, Authorization Bearer
tokens in Matrix/ntfy responses, Discord/Slack webhook tokens, URL
userinfo, and common ?token= query params.
"""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.redact import redact, redact_exc
@pytest.mark.parametrize(
"raw,expected_substr,not_in",
[
(
"Cannot connect to host api.telegram.org/bot1234567:AABBCC-secret-token/sendMessage",
"api.telegram.org/bot***",
"AABBCC-secret-token",
),
(
"Authorization: Bearer ey.JhbGciOiJIUzI1NiJ9.payload.sig",
"Bearer ***",
"ey.JhbGciOiJIUzI1NiJ9",
),
(
"POST https://discord.com/api/webhooks/12345/abcdefg-token failed",
"discord.com/api/webhooks/12345/***",
"abcdefg-token",
),
(
"POST https://hooks.slack.com/services/T01/B02/zzzzz failed",
"hooks.slack.com/services/T01/B02/***",
"zzzzz",
),
(
"fetch http://user:supersecret@example.com/foo",
"http://***@example.com/foo",
"supersecret",
),
(
"https://api.example.com/x?token=mytoken123&extra=ok",
"token=***",
"mytoken123",
),
],
)
def test_redact_known_secrets(raw: str, expected_substr: str, not_in: str) -> None:
out = redact(raw)
assert expected_substr in out
assert not_in not in out
def test_redact_idempotent() -> None:
once = redact("Bearer abcdefghij1234567890")
twice = redact(once)
assert once == twice
def test_redact_exc_returns_str() -> None:
err = RuntimeError("Bearer abcdefghij1234567890")
out = redact_exc(err)
assert isinstance(out, str)
assert "Bearer ***" in out
assert "abcdefghij1234567890" not in out
def test_redact_handles_non_string() -> None:
# Coercion path should not raise.
out = redact(12345) # type: ignore[arg-type]
assert out == "12345"
@@ -0,0 +1,73 @@
"""SSRF hardening regression tests.
Covers cases the original guard missed: IPv4-mapped IPv6, CGNAT,
trailing-dot hostnames, IPv6 zone identifiers, and the safe-host repr
used in error messages.
"""
from __future__ import annotations
import pytest
from notify_bridge_core.notifications.ssrf import (
UnsafeURLError,
PinnedResolver,
avalidate_outbound_url_full,
validate_outbound_url,
)
class TestBlockedRanges:
@pytest.mark.parametrize(
"url",
[
"http://[::ffff:127.0.0.1]/", # IPv4-mapped IPv6 → loopback
"http://[::ffff:10.0.0.1]/", # IPv4-mapped IPv6 → RFC1918
"http://100.64.0.1/", # CGNAT (RFC 6598)
"http://0.0.0.0/", # unspecified
],
)
def test_rejects_extra_ranges(self, url: str) -> None:
with pytest.raises(UnsafeURLError):
validate_outbound_url(url)
class TestHostnameNormalization:
def test_strips_trailing_dot(self) -> None:
# ``localhost.`` should normalize to ``localhost`` and still resolve
# to the loopback address — and be blocked.
with pytest.raises(UnsafeURLError):
validate_outbound_url("http://localhost./")
def test_rejects_bad_scheme_uppercase(self) -> None:
with pytest.raises(UnsafeURLError):
validate_outbound_url("FILE:///etc/passwd")
class TestErrorMessages:
def test_error_does_not_leak_long_hosts(self) -> None:
with pytest.raises(UnsafeURLError) as ei:
validate_outbound_url("http://" + "a" * 1024 + ".invalid/")
# Truncated to 64 chars in the error string.
assert len(str(ei.value)) < 256
class TestPinnedResolverSync:
def test_pin_returns_pinned_ip(self) -> None:
resolver = PinnedResolver({"example.com": "93.184.216.34"})
# Just exercise the dict path — full resolve runs in async tests.
assert resolver._map["example.com"] == "93.184.216.34" # type: ignore[attr-defined]
class TestAsyncFullValidator:
@pytest.mark.asyncio
async def test_returns_resolved_ip(self) -> None:
# Literal IP — no DNS lookup; we still get back a ValidatedURL.
result = await avalidate_outbound_url_full("http://8.8.8.8/")
assert result.ip == "8.8.8.8"
assert result.host == "8.8.8.8"
@pytest.mark.asyncio
async def test_rejects_blocked_literal(self) -> None:
with pytest.raises(UnsafeURLError):
await avalidate_outbound_url_full("http://[::ffff:127.0.0.1]/")
@@ -0,0 +1,56 @@
"""Telegram media-group mixed-type partitioning regression test.
Telegram rejects sendMediaGroup payloads that mix ``document`` with
``photo``/``video``. The client must partition before chunking so a
mixed input list still delivers all assets.
"""
from __future__ import annotations
from notify_bridge_core.notifications.telegram.client import TelegramClient
def test_partition_keeps_photo_video_together() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "photo", "url": "p1"},
{"type": "video", "url": "v1"},
{"type": "photo", "url": "p2"},
])
assert len(parts) == 1
assert [a["url"] for a in parts[0]] == ["p1", "v1", "p2"]
def test_partition_separates_documents_from_media() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "photo", "url": "p1"},
{"type": "document", "url": "d1"},
{"type": "video", "url": "v1"},
])
assert len(parts) == 3
assert parts[0][0]["url"] == "p1"
assert parts[1][0]["url"] == "d1"
assert parts[2][0]["url"] == "v1"
def test_partition_groups_consecutive_documents() -> None:
parts = TelegramClient._partition_media_by_kind([
{"type": "document", "url": "d1"},
{"type": "document", "url": "d2"},
{"type": "photo", "url": "p1"},
])
assert len(parts) == 2
assert [a["url"] for a in parts[0]] == ["d1", "d2"]
assert parts[1][0]["url"] == "p1"
def test_partition_empty() -> None:
assert TelegramClient._partition_media_by_kind([]) == []
def test_partition_defaults_missing_type_to_photo() -> None:
"""Items without an explicit type are treated as photos for grouping."""
parts = TelegramClient._partition_media_by_kind([
{"url": "x"}, # no type
{"type": "video", "url": "v"},
])
assert len(parts) == 1
@@ -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())