Compare commits

...

29 Commits

Author SHA1 Message Date
alexei.dolgolyov dfd7329177 chore: release v0.8.0
Release / release (push) Successful in 1m55s
2026-05-12 03:01:06 +03:00
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00
alexei.dolgolyov bb5afcc222 docs: expand README with all providers, targets, bot commands, and smart actions 2026-05-11 22:21:51 +03:00
alexei.dolgolyov 4335036c22 docs: sync README deploy section with actual env vars
Fix CORS default (was incorrectly listed as `*`, which is rejected on
startup) and document the env vars exposed by config.py and
docker-compose.yml — proxy/SSRF, auth, logging, retention, and
integration settings. Sync the Docker Compose example with the
hardened compose file at the repo root.
2026-05-11 21:50:31 +03:00
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
alexei.dolgolyov 349e9136a4 chore: release v0.6.5
Release / release (push) Successful in 1m36s
2026-04-28 19:10:49 +03:00
alexei.dolgolyov 04c8e3c8b2 feat(frontend): group command template slots into 4 logical fieldsets
Mirrors the notification-template page's group layout. Command slots
now split by name prefix into Command Responses, Error Messages
(rate_limited/no_results), Command Descriptions (desc_*), and Usage
Examples (usage_*). Language picker, reset-all, and slot filter are
hoisted above the groups so they apply across all fieldsets, and
empty groups are hidden so providers without usage_* don't render
empty headers.

Drops the orphan cmdTemplateConfig.commandResponsesHint i18n key —
hints.commandResponses replaces it.
2026-04-28 19:06:39 +03:00
alexei.dolgolyov 9afd38e50e fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh
- Add overscroll-behavior: contain to all in-modal/popup scroll
  containers (Modal body, EntitySelect, MultiEntitySelect, IconPicker,
  IconGridSelect, SearchPalette, TimezoneSelector) so reaching the
  inner scroll boundary no longer scrolls the page underneath.
- Telegram bot Discover Chats no longer collapses the existing chat
  list into a "Loading…" placeholder. Split chatsLoading (initial)
  from chatsRefreshing (Discover); rows are keyed by chat.id with
  flip+fade animations; the list dims with a sweeping shimmer bar
  while the Discover button shows a spinning icon and "Discovering
  chats…" label. Honors prefers-reduced-motion.
2026-04-28 18:52:20 +03:00
alexei.dolgolyov aa9548d884 chore: release v0.6.4
Release / release (push) Successful in 1m41s
2026-04-27 18:27:09 +03:00
alexei.dolgolyov 72dd611f8c fix(telegram): respect chat_action UI choice, drop phantom indicator
chat_action was stored in two places — the model column and config JSON —
and dispatch_helpers unconditionally overrode the config value with the
column. The frontend only ever wrote the JSON path, so the UI choice
silently had no effect on outgoing chat actions.

Make the column the single source of truth: frontend sends chat_action
top-level, dispatch_helpers reads from the column, and a one-time
backfill migrates existing config values to the column and strips the
legacy key.

Also fix a long-standing race where the keepalive's bare sleep(4) +
finally cancel could fire one last sendChatAction after the response
already arrived, leaving a phantom indicator for ~5s. Replace with a
stop event + wait_for so callers can signal stop cleanly via the new
stop_keepalive helper.
2026-04-27 18:20:50 +03:00
alexei.dolgolyov 0e675c4b38 chore: release v0.6.3
Release / release (push) Successful in 1m15s
2026-04-27 15:42:04 +03:00
alexei.dolgolyov 4307955163 feat(frontend): inject __APP_VERSION__ from package.json at build time
- vite.config.ts: read package.json and expose its version as a
  build-time global via Vite's `define`.
- app.d.ts: add ambient declaration so the layout's brand version
  badge (`v{__APP_VERSION__}`) type-checks.
2026-04-27 15:38:10 +03:00
alexei.dolgolyov b107b01a00 fix(redesign): prevent theme FOUC and sidebar jump on hard reload
- app.html: inline blocking script resolves the theme from localStorage
  (or prefers-color-scheme) and sets data-theme on <html> before first
  paint, eliminating the dark→light transition users saw when the light
  theme was selected.
- +layout.svelte: hydrate sidebar collapsed state and expanded nav groups
  synchronously in their $state initializers instead of inside onMount,
  so the sidebar no longer snaps from expanded→collapsed and groups no
  longer slide open after mount.
- +layout.svelte: keep the global provider-filter row rendered while
  providersCache.fetchedAt === 0, so the row doesn't pop in mid-paint
  and push the nav down once the cache resolves.
2026-04-27 15:38:03 +03:00
alexei.dolgolyov 42af7a6551 feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability
- Gitea: NotificationTracker now exposes sender allowlist / blocklist filters
  via MultiEntitySelect, populated from Gitea /users/search merged with past
  EventLog senders so the picker is useful before the first webhook arrives.
- Webhook providers (gitea, planka, webhook): stop scheduling interval polling
  jobs on tracker create/update/startup; hide the "every Xs" indicator in the
  tracker list since there is no polling.
- Dashboard: stat cards are now <a> links that route to providers, trackers,
  targets, command-trackers, or scroll to the events panel. Provider deck
  rows highlight the target provider on click.
- Command trackers / command configs: auto-reselect the right config when the
  provider type changes (matches notification-tracker behavior).
- Migration: drop legacy batch_duration column from notification_tracker —
  the field is gone from the model but its NOT NULL constraint blocked
  inserts on older DBs.
- Docs: refresh entity-relationships.md with current NotificationTracker
  fields (filters, adaptive_max_skip, default_*_config_id).
2026-04-27 15:24:44 +03:00
alexei.dolgolyov c43dc598a1 chore: release v0.6.2
Release / release (push) Successful in 1m39s
2026-04-27 14:29:44 +03:00
alexei.dolgolyov 1bfec521d8 fix(redesign): EntitySelect for language pickers + portal Timezone picker
- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- TimezoneSelector dropdown was being clipped by Card's overflow:hidden
  and backdrop-filter; portalled to <body> with an overlay backdrop and
  styled as a centered modal palette (same pattern as EntitySelect).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
2026-04-27 14:18:58 +03:00
alexei.dolgolyov b320090a56 chore: release v0.6.1
Release / release (push) Successful in 3m17s
2026-04-25 15:25:23 +03:00
alexei.dolgolyov cc8d961c33 fix(redesign): make Active Wires pipe visually prominent
Wire column was content-width (min 100px) so the line vanished between
two wide endpoint blocks. Bumped to minmax(220px, 1.6fr) so the pipe
takes ~60% more space than either side, thickened the line 2→3px,
faded both ends via color-mix transparency stops, added a soft
primary-glow halo plus a 1px specular sheen, and beefed up the count
badge with a rule-strong border / inset highlight / drop shadow so it
reads as a node on the wire. Stacks to a single column below 880px.
2026-04-25 15:23:30 +03:00
121 changed files with 15729 additions and 2659 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Entity Relationships
```
```text
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
TrackingConfig → provider_type, event flags, scheduling rules
TemplateConfig → provider_type, Jinja2 template slots per event type
+2
View File
@@ -56,3 +56,5 @@ frontend/.svelte-kit/
# Logs
*.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`)
- 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`
<!-- 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.
+142 -11
View File
@@ -2,20 +2,21 @@
A generic bridge between service providers and notification targets.
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
notifications to configurable targets (Telegram, webhooks) using customizable templates.
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
and internal scheduler) for changes and dispatches notifications to configurable targets
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
## Architecture
- **Service Providers** — Connectors to external services (Immich, more coming)
- **Service Providers** — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
- **Trackers** — Monitor specific collections within a provider for changes
- **Tracking Configs** — Define what events to watch for and scheduling rules
- **Notification Targets** — Where to send notifications (Telegram chats, webhook URLs)
- **Notification Targets** — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
- **Template Configs** — Jinja2 templates that format notifications per provider type
## Project Structure
```
```text
packages/
core/ — Shared library: providers, models, notifications, templates
server/ — FastAPI REST server with SQLite database
@@ -31,6 +32,7 @@ docker run -d \
-p 8420:8420 \
-v notify-bridge-data:/data \
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
```
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
### Environment Variables
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
| Variable | Required | Default | Description |
| -------- | -------- | ------- | ----------- |
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
| `CORS_ALLOWED_ORIGINS` | Recommended | `http://localhost:5175` | Comma-separated browser origins. Wildcard `*` is **rejected** because credentials are enabled. Set this to the URL you load the UI from. |
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
| `DATABASE_URL` | No | `sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db` | Override DB connection string. |
| `HOST` | No | `0.0.0.0` | Bind address. |
| `PORT` | No | `8420` | Server listen port. |
| `DEBUG` | No | `false` | Enable debug logging. |
Reverse proxy / network:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse proxy IP (e.g. `172.17.0.1` for the default Docker bridge). Use `*` only when the container is not directly internet-reachable. |
| `EXTERNAL_URL` | — | Public base URL (e.g. `https://notify.example.com`). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
| `ALLOW_PRIVATE_URLS` | unset | Set to `1` to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). **Do not enable on a publicly exposed instance.** |
Auth & tokens:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Lifetime of access JWTs. |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `30` | Lifetime of refresh tokens. |
| `JWT_ISSUER` | `notify-bridge` | `iss` claim. |
| `JWT_AUDIENCE` | `notify-bridge-api` | `aud` claim. |
Logging (all are also live-editable in the Settings page, except `log_format`):
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `LOG_LEVEL` | `INFO` | Root level: `DEBUG` / `INFO` / `WARNING` / `ERROR`. |
| `LOG_FORMAT` | `text` | `text` or `json`. Switching requires a restart. |
| `LOG_LEVELS` | — | Per-module overrides, e.g. `notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO`. |
Retention & maintenance:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history to keep. `0` disables the retention job. |
| `PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots to keep in `<DATA_DIR>/backups/`. `0` disables snapshotting. |
| `GRACEFUL_SHUTDOWN_SECONDS` | `60` | Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
Integrations & misc:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `TELEGRAM_WEBHOOK_SECRET` | — | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
| `TIMEZONE` | `UTC` | IANA timezone (e.g. `Europe/Warsaw`) used by the scheduler. Also settable from the Settings page. |
| `STATIC_DIR` | `/app/static` (in Docker) | Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
| `SUPERVISED` | auto-detect | Set to `1` to tell the backup endpoint that an external supervisor will restart the process. |
### Docker Compose
@@ -58,12 +107,50 @@ services:
volumes:
- notify-bridge-data:/data
environment:
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
# Comma-separated list of allowed browser origins. Wildcard `*` is
# rejected on startup because credentials are enabled.
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
# docker bridge, or `*` only if the container is NOT reachable from the
# public internet).
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
# enable on a publicly exposed instance.
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
# Use /api/ready (not /api/health) so the container is only reported
# healthy after migrations and the scheduler finish booting.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
cpus: 1.0
pids_limit: 256
volumes:
notify-bridge-data:
```
A ready-to-use `docker-compose.yml` lives at the repo root.
### Health & Readiness
- `GET /api/health` — process is up. Use for liveness probes.
- `GET /api/ready` — migrations + scheduler have booted. Use for readiness probes and Docker `HEALTHCHECK` (as the compose example above does).
## Quick Start (Development)
```bash
@@ -81,4 +168,48 @@ npm run dev
## Supported Providers
- **Immich** — Photo/video server with album change detection
- **Immich** — Photo/video server with album change detection (polling)
- **Gitea** — Git server with push / issue / PR / release events (webhook)
- **Planka** — Kanban board with card / list / board events (webhook)
- **NUT** — Network UPS Tools for battery / power events (polling)
- **Google Photos** — Album change detection (polling)
- **Generic Webhook** — Catch arbitrary JSON payloads and route them via templates (webhook)
- **Scheduler** — Internal provider for time-based scheduled messages
## Supported Notification Targets
- **Telegram** — Bot API with rich formatting, media groups, and inline commands
- **Discord** — Webhook-based delivery with embeds
- **Slack** — Incoming webhooks with Block Kit formatting
- **Matrix** — Homeserver delivery with HTML formatting
- **ntfy** — Self-hostable push notifications
- **Email** — SMTP with HTML / plain-text templates
- **Generic Webhook** — POST custom JSON payloads to any URL
## Bot Commands
Telegram bots can serve interactive commands per provider. All commands use
Jinja2 templates that you can customize from the **Command Templates** page.
| Provider | Commands |
| -------- | -------- |
| Immich | `/status` `/albums` `/events` `/summary` `/latest` `/memory` `/random` `/search` `/find` `/person` `/place` `/favorites` `/people` `/help` |
| Gitea | `/status` `/repos` `/issues` `/prs` `/commits` `/help` |
| Planka | `/status` `/boards` `/cards` `/lists` `/help` |
| NUT | `/status` `/devices` `/battery` `/help` |
| Google Photos | `/status` `/albums` `/latest` `/search` `/random` `/help` |
| Generic Webhook | `/status` `/help` |
Every provider also responds to `/start`, and rate-limit / empty-result
fallback messages are templated as well.
## Smart Actions
Beyond notifications, providers can run **actions** against the source service.
Currently implemented:
- **Immich — Auto-Organize** — Automatically sort newly-detected assets into
albums based on configurable rules. Each rule combines criteria (people in
the photo, search query, favorites, date range) with a target album, and can
create the album if it doesn't exist. Supports dry-run mode for previewing
what would move before committing.
+22 -36
View File
@@ -1,55 +1,41 @@
# v0.6.0 (2026-04-25)
This release ships the **Aurora redesign** of the frontend — a glass-and-tokens visual language applied across the dashboard, sidebar, page headers, and overlays — together with **per-chat command localization** for the Telegram bot.
# v0.8.0 (2026-05-12)
## User-facing changes
### Features
#### Frontend — Aurora redesign
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Aurora foundation: design tokens, glass sidebar, redesigned dashboard ([d9ef3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d9ef3c6))
- Project mockup richness onto the live dashboard ([d3210fd](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d3210fd))
- Subpage hero header, IconPicker portal, tighter gaps ([9733e5c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9733e5c))
- Roll subpage hero across all pages, plus Aurora Button, JinjaEditor, and pulse fix ([d662b50](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d662b50))
- Stack PageHeader meter top-right, action button bottom-right ([9643fe5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9643fe5))
- Collapsible dashboard sections + glass mobile-more sheet ([9eb76c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eb76c1))
### Documentation
#### Telegram
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
- Per-chat command localization with a unified locale resolver ([ef942b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ef942b7))
### Bug Fixes
- Portal IconGridSelect popup + snap navbar to mockup ([0105d9f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0105d9f))
- Brand snap, event sentences, palette glass, full-width layouts ([1895c5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1895c5e))
- Align topbar horizontal padding with page content ([46a4a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/46a4a6e))
- Portal overlays + solid popup surfaces for legibility ([d356e5a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d356e5a))
- A11y, mobile, and perf polish for production push ([711f218](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/711f218))
---
## Development / Internal
### Chores
### Architecture
- Add Aurora redesign mockups + chooser under `design-mockups/` ([1e35724](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1e35724))
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
### Tests
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [ef942b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ef942b7) | feat(telegram): per-chat command localization + unified locale resolver | alexei.dolgolyov |
| [711f218](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/711f218) | fix(redesign): a11y, mobile, perf polish for production push | alexei.dolgolyov |
| [9eb76c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eb76c1) | feat(redesign): collapsible dashboard sections + glass mobile-more sheet | alexei.dolgolyov |
| [d356e5a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d356e5a) | fix(redesign): portal overlays + solid popup surfaces for legibility | alexei.dolgolyov |
| [9643fe5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9643fe5) | feat(redesign): stack PageHeader meter top-right, button bottom-right | alexei.dolgolyov |
| [d662b50](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d662b50) | feat(redesign): roll subpage hero across all pages + Aurora Button + JinjaEditor + pulse fix | alexei.dolgolyov |
| [9733e5c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9733e5c) | feat(redesign): subpage hero header + iconpicker portal + tighter gaps | alexei.dolgolyov |
| [46a4a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/46a4a6e) | fix(redesign): align topbar horizontal padding with page content | alexei.dolgolyov |
| [1895c5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1895c5e) | fix(redesign): brand snap, event sentences, palette glass, full width | alexei.dolgolyov |
| [0105d9f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0105d9f) | fix(redesign): portal IconGridSelect popup + snap navbar to mockup | alexei.dolgolyov |
| [d3210fd](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d3210fd) | feat(redesign): project mockup richness onto live dashboard | alexei.dolgolyov |
| [d9ef3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d9ef3c6) | feat(redesign): aurora foundation — tokens, glass sidebar, dashboard | alexei.dolgolyov |
| [1e35724](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1e35724) | chore(design): add aurora redesign mockups + chooser | alexei.dolgolyov |
|------|---------|--------|
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | alexei.dolgolyov |
</details>
+20 -6
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.6.0",
"version": "0.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.6.0",
"version": "0.8.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
@@ -14,6 +14,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
@@ -607,6 +608,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"node_modules/@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -1464,7 +1473,7 @@
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
@@ -1587,7 +1596,7 @@
}
},
"node_modules/cookie": {
"version": "0.6.0",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
},
"@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -3417,7 +3431,7 @@
}
},
"@types/cookie": {
"version": "0.6.0",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
@@ -3502,7 +3516,7 @@
}
},
"cookie": {
"version": "0.6.0",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.6.0",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -34,6 +34,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
+52 -6
View File
@@ -1,11 +1,17 @@
@import '@fontsource/geist-sans/300.css';
@import '@fontsource/geist-sans/400.css';
@import '@fontsource/geist-sans/500.css';
@import '@fontsource/geist-sans/600.css';
@import '@fontsource/geist-sans/700.css';
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
so RU and EN render in the same font instead of falling back to a
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
(latin-only) imports — see --font-sans below for the family rename. */
@import '@fontsource-variable/geist';
@import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/500.css';
@import '@fontsource/geist-mono/600.css';
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
declarations so Russian text renders in Geist Mono instead of falling
back to Cascadia/Consolas. */
@import '@fontsource/geist-mono/cyrillic-400.css';
@import '@fontsource/geist-mono/cyrillic-500.css';
@import '@fontsource/geist-mono/cyrillic-600.css';
@import '@fontsource/newsreader/300-italic.css';
@import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.css';
@@ -68,7 +74,7 @@
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif;
@@ -371,6 +377,46 @@ button:focus-visible, a:focus-visible {
.stagger-children > * {
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
}
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
Full-bleed rows that stretch to the main column width. Pair with .list-row
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
.list-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.list-row {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.list-row__identity {
min-width: 0;
flex: 0 0 auto;
max-width: 28rem;
}
@media (max-width: 1023px) {
.list-row__identity { flex: 1 1 auto; }
}
.list-row__actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* Secondary text under the name — visible only when meta-strip is hidden
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
.list-row__secondary {
display: block;
}
@media (min-width: 1024px) {
.list-row__secondary { display: none; }
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
+16
View File
@@ -0,0 +1,16 @@
// Ambient type declarations for SvelteKit + project-level build-time globals.
declare global {
/** App version, injected from frontend/package.json at build time. */
const __APP_VERSION__: string;
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+17
View File
@@ -5,6 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Notify Bridge</title>
<script>
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
(function () {
try {
var saved = localStorage.getItem('theme');
var resolved =
saved === 'light' || saved === 'dark'
? saved
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', resolved);
} catch (_) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+40 -19
View File
@@ -20,7 +20,10 @@
noneLabel = '—',
disabled = false,
size = 'default',
open = $bindable(false),
showTrigger = true,
onselect,
onclose,
}: {
items: EntityItem[];
value: string | number | null;
@@ -29,10 +32,12 @@
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
open?: boolean;
showTrigger?: boolean;
onselect?: (value: string | number | null) => void;
onclose?: () => void;
} = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>();
@@ -52,24 +57,37 @@
return [...result, ...matching];
});
// Focus input whenever the palette transitions to open (covers both internal
// trigger clicks and external programmatic opening via bind:open).
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
wasOpen = open;
});
function openPalette() {
if (disabled) return;
open = true;
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
// Called when the user dismisses the palette (overlay click or ESC).
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
function closePalette() {
open = false;
query = '';
onclose?.();
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
open = false;
query = '';
}
function handleKeydown(e: KeyboardEvent) {
@@ -106,21 +124,23 @@
});
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
{#if showTrigger}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
{/if}
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open}
@@ -304,6 +324,7 @@
/* List */
.ep-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -0,0 +1,449 @@
<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();
// Retain the last non-null event so the modal body stays populated
// while the close transition plays after the parent clears `event`.
let displayEvent = $state<EventLog | null>(null);
$effect(() => {
if (event) displayEvent = event;
});
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
*
* Used by the deferred-dispatch lifecycle banner to render
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
* integer that the user has to mentally divide. Keeps two units so
* the magnitude reads correctly across hours-long quiet windows
* without becoming noisy for short ones. */
function humanDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
}
/** Render an absolute ISO timestamp as a future-relative string.
*
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
* deferred-until banner shouldn't show a relative offset once the
* window has already ended (a follow-up event_log row marks delivery).
*/
function timeFromNow(iso: string | undefined): string {
if (!iso) return '';
try {
const target = new Date(iso).getTime();
const diff = Math.floor((target - Date.now()) / 1000);
if (diff <= 0) return '';
return humanDuration(diff);
} catch {
return '';
}
}
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(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuerText = $derived(issuerLabel(issuer));
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!displayEvent?.details) return '';
try {
return JSON.stringify(displayEvent.details, null, 2);
} catch {
return String(displayEvent.details);
}
});
</script>
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
{#if displayEvent}
<div class="event-detail">
<!-- Subject + verb -->
<div class="hero-row">
<MdiIcon name="mdiBell" size={18} />
<div>
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
<div class="hero-meta">
<span class="event-type">{displayEvent.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(displayEvent.created_at)}</span>
</div>
</div>
</div>
<!-- Dispatch lifecycle (only when the event went through the
quiet-hours defer path). Rendered ABOVE the provenance grid
because timing of delivery is more interesting than the
bot/tracker names when the event is held back. -->
{#if displayEvent.details?.dispatch_status === 'deferred'}
<section class="lifecycle lifecycle--deferred">
<MdiIcon name="mdiPauseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
<div class="lifecycle-detail">
{t('events.lifecycle.heldUntil')}
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
{#if timeFromNow(displayEvent.details.deferred_until)}
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
{/if}
</div>
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
<section class="lifecycle lifecycle--late">
<MdiIcon name="mdiClockCheckOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
{#if displayEvent.details.deferred_for_seconds != null}
<div class="lifecycle-detail">
{t('events.lifecycle.heldFor')}
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiCloseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiAlertCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiVolumeOff" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
</div>
</section>
{/if}
<!-- Provenance grid -->
<dl class="provenance">
{#if displayEvent.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{displayEvent.bot_name}</dd>
{/if}
{#if displayEvent.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{displayEvent.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 displayEvent.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{displayEvent.command_tracker_name}</dd>
{/if}
{#if displayEvent.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{displayEvent.tracker_name}</dd>
{/if}
{#if displayEvent.action_name}
<dt>{t('events.action')}</dt>
<dd>{displayEvent.action_name}</dd>
{/if}
{#if displayEvent.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{displayEvent.provider_name}</dd>
{/if}
{#if displayEvent.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{displayEvent.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<div class="actions">
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.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); }
/* Dispatch lifecycle banner — appears only when the event took the
* quiet-hours defer path. The three colour variants mirror the dashboard
* badge palette: primary glow for "held", success for "delivered late",
* muted/dim for "dropped" / "failed" / "suppressed".
*/
.lifecycle {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.75rem 0.95rem;
border-radius: 0.7rem;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.lifecycle-body {
display: flex; flex-direction: column; gap: 0.2rem;
flex: 1; min-width: 0;
}
.lifecycle-title {
font-weight: 600;
color: var(--color-foreground);
}
.lifecycle-detail {
color: var(--color-foreground);
}
.lifecycle-detail b {
font-family: var(--font-mono);
font-weight: 600;
}
.lifecycle-rel {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-left: 0.25rem;
}
.lifecycle-hint {
color: var(--color-muted-foreground);
font-size: 0.72rem;
}
.lifecycle-reason {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
word-break: break-all;
}
.lifecycle--deferred {
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.lifecycle--deferred :global(svg) {
color: var(--color-primary);
}
.lifecycle--late {
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
}
.lifecycle--late :global(svg) {
color: var(--color-success, #16a34a);
}
.lifecycle--dropped {
opacity: 0.92;
}
.lifecycle--dropped :global(svg) {
color: var(--color-muted-foreground);
}
</style>
@@ -195,6 +195,7 @@
padding: 0.5rem;
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
@@ -188,6 +188,7 @@
max-height: 14rem;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
position: relative;
z-index: 1;
+103 -279
View File
@@ -1,48 +1,10 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { LOCALE_CATALOG, getLocaleMeta, type LocaleMeta } from '$lib/locales';
import EntitySelect, { type EntityItem } from './EntitySelect.svelte';
interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
const CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
const CATALOG: LocaleMeta[] = LOCALE_CATALOG;
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
@@ -76,11 +38,7 @@
}
function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
return getLocaleMeta(code);
}
function remove(code: string) {
@@ -109,79 +67,48 @@
// --- Add flow ----------------------------------------------------------
let addOpen = $state(false);
let addQuery = $state('');
let addInputEl = $state<HTMLInputElement | null>(null);
let highlightIdx = $state(0);
// Valid BCP 47-ish: 23 letter primary, optional '-' subtag(s) 2-8 chars.
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes));
const suggestions = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
const available = CATALOG.filter(l => !selectedSet.has(l.code));
if (!q) return available;
return available.filter(l =>
l.code.includes(q)
|| l.name.toLowerCase().includes(q)
|| l.native.toLowerCase().includes(q),
);
});
/**
* Catalog languages not yet selected, surfaced through EntitySelect.
* Native name is the label so the user sees their own script; the
* English name + code lives in the description for searchability.
*/
const addItems = $derived<EntityItem[]>(
CATALOG
.filter(l => !selectedSet.has(l.code))
.map(l => ({
value: l.code,
label: l.native,
desc: `${l.name} · ${l.code.toUpperCase()}`,
})),
);
const canAddCustom = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
if (!q) return false;
if (!CUSTOM_RE.test(q)) return false;
if (selectedSet.has(q)) return false;
// Skip "custom" entry when it matches an existing catalog entry exactly.
if (CATALOG.some(l => l.code === q)) return false;
let customCode = $state('');
const customCodeValid = $derived.by(() => {
const c = customCode.trim().toLowerCase();
if (!c || !CUSTOM_RE.test(c)) return false;
if (selectedSet.has(c)) return false;
if (CATALOG.some(l => l.code === c)) return false;
return true;
});
function openAdd() {
addOpen = true;
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function closeAdd() {
addOpen = false;
addQuery = '';
}
function addCode(code: string) {
const c = code.trim().toLowerCase();
function addCode(code: string | number | null) {
if (code === null) return;
const c = String(code).trim().toLowerCase();
if (!c) return;
commit([...codes, c]);
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function onAddKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closeAdd(); return; }
const total = suggestions.length + (canAddCustom ? 1 : 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx < suggestions.length) {
addCode(suggestions[highlightIdx].code);
} else if (canAddCustom) {
addCode(addQuery);
}
}
function addCustom() {
if (!customCodeValid) return;
addCode(customCode);
customCode = '';
}
$effect(() => { addQuery; highlightIdx = 0; });
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
@@ -329,77 +256,39 @@
</ul>
{/if}
<!-- Add zone -->
<div class="ls-add" class:ls-add-open={addOpen}>
{#if !addOpen}
<button type="button" class="ls-add-trigger" onclick={openAdd}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('locales.add')}</span>
</button>
{:else}
<div class="ls-add-panel">
<div class="ls-add-input-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={addInputEl}
bind:value={addQuery}
onkeydown={onAddKeydown}
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
placeholder={t('locales.searchPlaceholder')}
class="ls-add-input"
autocomplete="off"
spellcheck="false"
type="text"
/>
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
<div class="ls-add-list" role="listbox">
{#each suggestions as s, i (s.code)}
<button
type="button"
role="option"
aria-selected={i === highlightIdx}
class="ls-sugg"
class:ls-sugg-hl={i === highlightIdx}
onmouseenter={() => highlightIdx = i}
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
>
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
<span class="ls-sugg-name">{s.name}</span>
<span class="ls-sugg-code">{s.code}</span>
{#if SHIPPED.has(s.code)}
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
</span>
{/if}
</button>
{/each}
{#if canAddCustom}
<button
type="button"
role="option"
aria-selected={highlightIdx === suggestions.length}
class="ls-sugg ls-sugg-custom"
class:ls-sugg-hl={highlightIdx === suggestions.length}
onmouseenter={() => highlightIdx = suggestions.length}
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
>
<MdiIcon name="mdiPlusCircleOutline" size={14} />
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
</button>
{/if}
{#if suggestions.length === 0 && !canAddCustom}
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
{/if}
</div>
<!-- Add zone — EntitySelect for catalog languages, separate input for custom BCP-47 codes -->
<div class="ls-add">
<div class="ls-add-row">
<div class="ls-add-picker">
<EntitySelect
items={addItems}
value={null}
placeholder={t('locales.add')}
size="sm"
onselect={addCode}
/>
</div>
{/if}
<div class="ls-add-custom">
<input
type="text"
bind:value={customCode}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
placeholder={t('locales.customPlaceholder')}
class="ls-add-custom-input"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="ls-add-custom-btn"
disabled={!customCodeValid}
onclick={addCustom}
title={t('locales.addCustom')}
>
<MdiIcon name="mdiPlus" size={14} />
</button>
</div>
</div>
</div>
<p class="ls-hint">
@@ -630,125 +519,60 @@
.ls-add {
margin-top: 0.125rem;
}
.ls-add-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ls-add-trigger:hover {
border-color: var(--color-primary);
border-style: solid;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.ls-add-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
overflow: hidden;
animation: ls-pop 0.15s ease-out;
}
@keyframes ls-pop {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.ls-add-input-row {
.ls-add-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
flex-wrap: wrap;
}
.ls-add-input {
.ls-add-picker {
flex: 1;
min-width: 12rem;
}
.ls-add-custom {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.15rem 0.15rem 0.55rem;
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
background: transparent;
}
.ls-add-custom-input {
width: 6rem;
border: none;
outline: none;
background: transparent;
font-size: 0.8rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.ls-add-list {
max-height: 14rem;
overflow-y: auto;
scrollbar-width: thin;
}
.ls-sugg {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ls-sugg.ls-sugg-hl {
background: var(--color-muted);
}
.ls-sugg-native {
font-size: 0.9rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-sugg-name {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.ls-sugg-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
font-size: 0.75rem;
color: var(--color-foreground);
padding: 0.25rem 0;
}
.ls-add-custom-input::placeholder {
color: var(--color-muted-foreground);
opacity: 0.7;
}
.ls-sugg.ls-sugg-hl .ls-sugg-code {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
.ls-add-custom-btn {
display: inline-flex;
align-items: center;
color: var(--color-primary);
opacity: 0.85;
}
.ls-sugg-custom {
border-top: 1px dashed var(--color-border);
color: var(--color-primary);
}
.ls-sugg-custom-label {
font-size: 0.75rem;
font-weight: 500;
}
.ls-sugg-empty {
padding: 0.75rem;
font-size: 0.75rem;
text-align: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-add-custom-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-primary);
}
.ls-add-custom-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ---- Hint --------------------------------------------------------- */
@@ -0,0 +1,187 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
export type MetaTone = 'default' | 'mint' | 'sky' | 'coral' | 'citrus' | 'orchid' | 'lavender';
export interface MetaTile {
icon?: string;
label: string;
value?: string;
hint?: string;
tone?: MetaTone;
mono?: boolean;
href?: string;
onclick?: (e: MouseEvent) => void;
copyValue?: string;
}
let { tiles, align = 'start' }: {
tiles: MetaTile[];
align?: 'start' | 'end';
} = $props();
function handleClick(e: MouseEvent, tile: MetaTile) {
if (tile.onclick) {
e.preventDefault();
e.stopPropagation();
tile.onclick(e);
}
}
</script>
<div class="meta-strip" style="justify-content: {align === 'end' ? 'flex-end' : 'flex-start'};">
{#each tiles as tile, i (i)}
{#if tile.href}
<a
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
href={tile.href}
target="_blank"
rel="noopener"
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</a>
{:else if tile.onclick}
<button
type="button"
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
onclick={(e: MouseEvent) => handleClick(e, tile)}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</button>
{:else}
<div
class="meta-tile meta-tone-{tile.tone || 'default'}"
class:meta-tile--mono={tile.mono}
title={tile.hint}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</div>
{/if}
{/each}
</div>
<style>
.meta-strip {
display: none;
min-width: 0;
flex: 1 1 auto;
gap: 0.45rem;
align-items: center;
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
padding: 2px 18px;
}
@media (min-width: 1024px) {
.meta-strip {
display: flex;
}
}
.meta-tile {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: var(--color-glass);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid var(--color-border);
font-size: 0.72rem;
line-height: 1.1;
color: var(--color-muted-foreground);
white-space: nowrap;
flex-shrink: 0;
max-width: 22rem;
min-width: 0;
text-decoration: none;
font-family: inherit;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.meta-tile__icon {
display: inline-flex;
align-items: center;
color: currentColor;
opacity: 0.9;
flex-shrink: 0;
}
.meta-tile__text {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
}
.meta-tile__value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.meta-tile__label {
font-size: 0.72rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.meta-tile--mono .meta-tile__label,
.meta-tile--mono .meta-tile__value {
font-family: var(--font-mono);
letter-spacing: -0.02em;
font-size: 0.7rem;
}
.meta-tile--interactive {
cursor: pointer;
}
.meta-tile--interactive:hover {
border-color: var(--color-rule-strong);
background: var(--color-glass-strong);
transform: translateY(-1px);
}
/* Tone variants — applied to the dot/icon and accent border on hover */
.meta-tone-mint { box-shadow: inset 2px 0 0 var(--color-mint); }
.meta-tone-sky { box-shadow: inset 2px 0 0 var(--color-sky); }
.meta-tone-coral { box-shadow: inset 2px 0 0 var(--color-coral); }
.meta-tone-citrus { box-shadow: inset 2px 0 0 var(--color-citrus); }
.meta-tone-orchid { box-shadow: inset 2px 0 0 var(--color-orchid); }
.meta-tone-lavender { box-shadow: inset 2px 0 0 var(--color-primary); }
.meta-tone-mint .meta-tile__icon { color: var(--color-mint); }
.meta-tone-sky .meta-tile__icon { color: var(--color-sky); }
.meta-tone-coral .meta-tile__icon { color: var(--color-coral); }
.meta-tone-citrus .meta-tile__icon { color: var(--color-citrus); }
.meta-tone-orchid .meta-tile__icon { color: var(--color-orchid); }
.meta-tone-lavender .meta-tile__icon { color: var(--color-primary); }
</style>
+16 -2
View File
@@ -11,14 +11,22 @@
}>();
let visible = $state(false);
let mounted = $state(false);
let panelEl = $state<HTMLDivElement | undefined>();
let previouslyFocused: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
const TRANSITION_MS = 250;
$effect(() => {
if (open) {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
previouslyFocused = document.activeElement as HTMLElement | null;
mounted = true;
requestAnimationFrame(() => {
visible = true;
// Focus first focusable element inside the modal
@@ -29,13 +37,18 @@
focusable?.focus();
});
});
} else {
} else if (mounted) {
visible = false;
// Restore focus to the previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
mounted = false;
closeTimer = null;
}, TRANSITION_MS);
}
});
@@ -73,7 +86,7 @@
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
{#if mounted}
<div use:portal class="modal-portal-root">
<div
class="modal-backdrop"
@@ -192,6 +205,7 @@
z-index: 1;
padding: 0 1.5rem 1.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.modal-close {
@@ -307,6 +307,7 @@
.mes-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.25rem 0;
}
@@ -342,6 +342,7 @@
.sp-results {
max-height: 52vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
let {
value = $bindable<string>('UTC'),
@@ -172,18 +173,12 @@
$effect(() => { query; highlightIdx = 0; });
// Close on outside click
function onDocClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node;
if (panelEl && !panelEl.contains(target)) closePicker();
}
onMount(() => {
document.addEventListener('mousedown', onDocClick);
});
onDestroy(() => {
document.removeEventListener('mousedown', onDocClick);
});
/**
* The panel is portalled to <body> to escape Card's overflow:hidden +
* backdrop-filter (which would otherwise clip and stacking-trap the
* dropdown). Outside-click is detected via the dedicated overlay div
* rather than a document listener, so we don't need a global handler.
*/
</script>
<div class="tz-root">
@@ -217,83 +212,87 @@
</button>
{#if open}
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
<div use:portal class="tz-portal-root">
<div class="tz-overlay" onclick={closePicker} role="presentation"></div>
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
@@ -408,35 +407,66 @@
align-items: center;
}
/* ---- Panel -------------------------------------------------------- */
.tz-panel {
/* ---- Portal + overlay (escapes Card's overflow:hidden / backdrop-filter) ---- */
.tz-portal-root {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.tz-overlay {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 20;
background: var(--color-card, var(--color-background));
border: 1px solid var(--color-border);
border-radius: 0.625rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
inset: 0;
pointer-events: auto;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
}
/* ---- Panel (centered modal palette) -------------------------------- */
.tz-panel {
pointer-events: auto;
position: absolute;
top: min(20vh, 120px);
left: 50%;
transform: translateX(-50%);
z-index: 1;
width: min(540px, 92vw);
max-height: min(60vh, 30rem);
background: var(--tz-solid-bg);
border: 1px solid var(--color-rule-strong, var(--color-border));
border-radius: 16px;
box-shadow: var(--shadow-card, 0 18px 40px rgba(0, 0, 0, 0.35)),
0 24px 48px -16px rgba(0, 0, 0, 0.55);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 26rem;
animation: tz-pop 0.15s ease-out;
--tz-solid-bg: #131520;
}
:global([data-theme="light"]) .tz-panel { --tz-solid-bg: #fafafe; }
.tz-panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight, transparent), transparent 30%);
opacity: 0.4;
}
@keyframes tz-pop {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translate(-50%, -3px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
position: relative;
z-index: 1;
}
.tz-search {
flex: 1;
@@ -464,6 +494,8 @@
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.tz-quick-btn {
display: inline-flex;
@@ -498,8 +530,14 @@
.tz-list {
overflow-y: auto;
padding: 0.25rem 0;
overscroll-behavior: contain;
/* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
padding: 0 0 0.25rem;
scrollbar-width: thin;
position: relative;
z-index: 1;
}
.tz-empty {
padding: 1rem;
@@ -523,7 +561,7 @@
color: var(--color-muted-foreground);
position: sticky;
top: 0;
background: var(--color-card, var(--color-background));
background: var(--tz-solid-bg);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1;
}
+32
View File
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
{ 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 ---
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_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
];
// --- Sort filter (dashboard) ---
@@ -101,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Auto-refresh interval (dashboard events list) ---
//
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
// in routes/+page.svelte if you add or remove cadences.
export const refreshIntervalItems = (): GridItem[] => [
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
];
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
+218 -6
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Service notifications"
},
"crumbs": {
"routingNotification": "Routing · Notification",
"routingCommands": "Routing · Commands",
"routingTargets": "Routing · Targets",
"routingAutomation": "Routing · Automation",
"operatorsBots": "Operators · Bots",
"systemAccess": "System · Access",
"systemConfiguration": "System · Configuration",
"systemMaintenance": "System · Maintenance",
"serviceConnections": "Service · Connections"
},
"nav": {
"sectionOverview": "Overview",
"sectionRouting": "Routing",
@@ -87,6 +98,15 @@
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"commandHandled": "command handled",
"commandRateLimited": "rate limited",
"commandFailed": "command failed",
"autoRefreshTitle": "Auto-refresh interval for the events list",
"refreshOff": "Off",
"refresh10s": "10s",
"refresh30s": "30s",
"refresh60s": "1m",
"refresh5m": "5m",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -97,10 +117,22 @@
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"filterCommandHandled": "Command Handled",
"filterCommandRateLimited": "Rate Limited",
"filterCommandFailed": "Command Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"heldUntil": "held until",
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
"deliveredLate": "delivered late",
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
"deferredThenDropped": "dropped after defer",
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
"deferredThenFailed": "failed after defer",
"suppressedQuietHours": "suppressed (quiet hours)",
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
"asset": "asset",
"assets": "assets",
"eventActivity": "Event Activity",
@@ -141,6 +173,37 @@
"newTracker": "New tracker",
"eventsTotal": "Events"
},
"events": {
"detailTitle": "Event details",
"bot": "Bot",
"chat": "Chat",
"issuer": "Issued by",
"commandTracker": "Command tracker",
"tracker": "Tracker",
"action": "Action",
"provider": "Provider",
"assetsCount": "Assets",
"openProvider": "Open provider",
"openBot": "Open bot",
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details",
"lifecycle": {
"heldTitle": "Held by quiet hours",
"heldUntil": "Will dispatch at",
"heldFor": "Held for",
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
"inPrefix": "in",
"deliveredLateTitle": "Delivered after quiet hours",
"originalEvent": "Original event",
"droppedTitle": "Dropped after defer",
"failedTitle": "Failed after defer",
"reason": "Reason",
"suppressedTitle": "Suppressed by quiet hours",
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
}
},
"providers": {
"title": "Service",
"titleEmphasis": "providers",
@@ -192,7 +255,8 @@
"apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"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",
"nutHostPlaceholder": "192.168.1.100 or ups.local",
"nutPort": "NUT Server Port",
@@ -246,6 +310,9 @@
"selectAlbums": "Select albums...",
"repositories": "Repositories",
"selectRepositories": "Select repositories...",
"userAllowlist": "Only from users",
"userBlocklist": "Exclude users",
"selectUsers": "Pick users...",
"boards": "Boards",
"selectBoards": "Select boards...",
"upsDevices": "UPS Devices",
@@ -309,6 +376,7 @@
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
@@ -416,13 +484,20 @@
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
"receiverDisabled": "Receiver disabled",
"groupNoBot": "No bot linked",
"groupDirect": "Direct delivery",
"groupBotMissing": "Unknown bot",
"target": "target",
"targetsLower": "targets",
"openBot": "Open bot"
},
"users": {
"titleEmphasis": "& access",
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"you": "you",
"addUser": "Add User",
"cancel": "Cancel",
"username": "Username",
@@ -472,6 +547,7 @@
"noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram",
"discoveringChats": "Discovering chats…",
"clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered",
"chatDeleted": "Chat removed",
@@ -627,6 +703,7 @@
"countLabel": "templates",
"title": "Template Configs",
"description": "Define how notification messages are formatted",
"language": "Language",
"providerType": "Service Provider Type",
"newConfig": "New Config",
"name": "Name",
@@ -783,7 +860,92 @@
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
"logLevels": "Per-Module Overrides",
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Settings saved"
"saved": "Settings saved",
"identity": "Identity",
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
"telegramHeadline": "Webhook authentication and media cache tuning",
"loggingHeadline": "Verbosity, output format, and per-module overrides",
"heroNoUrl": "External URL not set",
"heroNoLocales": "no locales",
"copy": "Copy",
"urlCopied": "URL copied",
"openExternal": "Open",
"show": "Show",
"hide": "Hide",
"secretSet": "Verified",
"secretUnset": "Not configured",
"cacheConfig": "Cache",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Max entries",
"cacheMaxFootnote": "per bucket (LRU)",
"hoursShort": "hrs",
"entriesShort": "max",
"ttlNoExpiry": "no expiry",
"cacheCapacity": "Cache capacity",
"cacheCapacityCap": "of {n} cap",
"logModulePlaceholder": "module.path",
"addOverride": "Add override",
"removeOverride": "Remove",
"editAsText": "Edit as text",
"editAsChips": "Edit as chips",
"logPreviewLabel": "ACTIVE",
"unsavedChanges": "Unsaved changes",
"unsaved": "UNSAVED",
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes",
"release": {
"eyebrow": "Releases",
"headline": "Stay current with upstream",
"provider": "Provider",
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
"comingSoon": "Coming soon",
"disabled": "Disabled",
"repository": "Repository",
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
"options": "Options",
"includePrereleases": "Include pre-releases",
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
"interval": "Check interval",
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
"intervalRange": "1168 hrs",
"hoursUnit": "hrs",
"testConnection": "Test connection",
"checkNow": "Check now",
"checkDone": "Release check complete",
"checkFailed": "Release check failed",
"testOk": "Provider reachable",
"testFailed": "Provider unreachable",
"testFound": "Provider returned",
"viewRelease": "View v{v} release",
"statusUpToDate": "You're up to date",
"statusUpdate": "Update available",
"statusDisabled": "Release checks disabled",
"statusError": "Last check failed",
"statusUnknown": "Not checked yet",
"heroAvailable": "available",
"updateAvailableTooltip": "v{v} available — open Settings",
"lastChecked": "Last checked",
"never": "never",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} d ago",
"error": {
"disabled": "Release checks are disabled",
"misconfigured": "Provider not fully configured",
"provider_changed": "Provider changed — awaiting next check",
"no_release_found": "No matching release found upstream",
"network_error": "Upstream unreachable",
"http_error": "Upstream returned an error",
"parse_error": "Upstream response could not be parsed",
"unsafe_url": "URL rejected by safety check",
"not_implemented": "Provider not implemented yet",
"unknown_error": "Unknown error",
"error": "Last check failed"
}
}
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -818,7 +980,11 @@
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -871,12 +1037,15 @@
"noConfigs": "No command template configs yet.",
"confirmDelete": "Delete this command template config?",
"commandResponses": "Command Responses",
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response."
"commandErrors": "Error Messages",
"commandDescriptions": "Command Descriptions",
"commandUsage": "Usage Examples"
},
"commandConfig": {
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Command Configs",
"noCommandsForProvider": "No commands available for this provider type.",
"description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config",
"name": "Name",
@@ -937,9 +1106,12 @@
"noMatches": "No timezones match"
},
"locales": {
"label": "language",
"labelPlural": "languages",
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"customPlaceholder": "or de-CH",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary",
@@ -1012,6 +1184,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1119,6 +1293,12 @@
"memorySourceNative": "Use Immich native memories API",
"localeEn": "English 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",
"modeText": "Send file names and links only",
"allEvents": "Show all event types",
@@ -1130,6 +1310,14 @@
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"commandHandled": "Bot command served",
"commandRateLimited": "Bot command throttled",
"commandFailed": "Bot command crashed",
"refreshOff": "Auto-refresh disabled",
"refresh10s": "Refresh every 10 seconds",
"refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1312,6 +1500,30 @@
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
"restartingDescription": "The page will reload once the server is back online.",
"countLabel": "backups",
"scheduleOn": "Auto · every {h}h",
"scheduleOff": "Auto backup off",
"lastBackup": "Last {ago}",
"never": "no backups yet",
"totalSize": "{size} total",
"dropZone": "Drop a JSON backup here, or click to choose",
"dropZoneActive": "Release to load",
"changeFile": "Change file",
"catGroupIdentity": "Identity & Routing",
"catGroupNotif": "Notifications",
"catGroupCmd": "Commands",
"catGroupSystem": "System",
"stepCategories": "What to include",
"stepSecrets": "Secrets handling",
"stepDownload": "Download",
"stepFile": "Choose a file",
"stepValidate": "Validate contents",
"stepConflict": "On conflict",
"stepApply": "Apply",
"tagScheduled": "scheduled",
"tagManual": "manual",
"tagSecrets": "with secrets",
"validateFirst": "Validate the file first to enable import"
}
}
+218 -6
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Уведомления о сервисах"
},
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": {
"sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация",
@@ -87,6 +98,15 @@
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"commandHandled": "команда обработана",
"commandRateLimited": "ограничение частоты",
"commandFailed": "команда упала",
"autoRefreshTitle": "Интервал авто-обновления списка событий",
"refreshOff": "Выкл",
"refresh10s": "10с",
"refresh30s": "30с",
"refresh60s": "1м",
"refresh5m": "5м",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -97,10 +117,22 @@
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"filterCommandHandled": "Команда обработана",
"filterCommandRateLimited": "Ограничение частоты",
"filterCommandFailed": "Команда упала",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"heldUntil": "ожидает до",
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
"deliveredLate": "доставлено позже",
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
"deferredThenDropped": "отброшено после задержки",
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
"deferredThenFailed": "ошибка после задержки",
"suppressedQuietHours": "подавлено (тихие часы)",
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
"asset": "файл",
"assets": "файлов",
"eventActivity": "Активность событий",
@@ -141,6 +173,37 @@
"newTracker": "Новый трекер",
"eventsTotal": "Событий"
},
"events": {
"detailTitle": "Детали события",
"bot": "Бот",
"chat": "Чат",
"issuer": "Отправитель",
"commandTracker": "Командный трекер",
"tracker": "Трекер",
"action": "Действие",
"provider": "Провайдер",
"assetsCount": "Файлов",
"openProvider": "Открыть провайдера",
"openBot": "Открыть бота",
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные",
"lifecycle": {
"heldTitle": "Задержано тихими часами",
"heldUntil": "Будет отправлено в",
"heldFor": "Задержано на",
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
"inPrefix": "через",
"deliveredLateTitle": "Доставлено после тихих часов",
"originalEvent": "Исходное событие",
"droppedTitle": "Отброшено после задержки",
"failedTitle": "Ошибка после задержки",
"reason": "Причина",
"suppressedTitle": "Подавлено тихими часами",
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
}
},
"providers": {
"title": "Сервисные",
"titleEmphasis": "провайдеры",
@@ -192,7 +255,8 @@
"apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"webhookUrl": "URL вебхука",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
"nutHost": "Хост NUT-сервера",
"nutHostPlaceholder": "192.168.1.100 или ups.local",
"nutPort": "Порт NUT-сервера",
@@ -246,6 +310,9 @@
"selectAlbums": "Выберите альбомы...",
"repositories": "Репозитории",
"selectRepositories": "Выберите репозитории...",
"userAllowlist": "Только от пользователей",
"userBlocklist": "Исключить пользователей",
"selectUsers": "Выберите пользователей...",
"boards": "Доски",
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
@@ -309,6 +376,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -416,13 +484,20 @@
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
"receiverDisabled": "Получатель отключён",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
"target": "получатель",
"targetsLower": "получателей",
"openBot": "Открыть бота"
},
"users": {
"titleEmphasis": "и доступ",
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -472,6 +547,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
@@ -627,6 +703,7 @@
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений",
"language": "Язык",
"providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -783,7 +860,92 @@
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Настройки сохранены"
"saved": "Настройки сохранены",
"identity": "Идентификация",
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
"heroNoUrl": "Внешний URL не задан",
"heroNoLocales": "нет локалей",
"copy": "Копировать",
"urlCopied": "URL скопирован",
"openExternal": "Открыть",
"show": "Показать",
"hide": "Скрыть",
"secretSet": "Задан",
"secretUnset": "Не настроен",
"cacheConfig": "Кэш",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Макс. записей",
"cacheMaxFootnote": "на корзину (LRU)",
"hoursShort": "ч",
"entriesShort": "макс",
"ttlNoExpiry": "без срока",
"cacheCapacity": "Заполненность кэша",
"cacheCapacityCap": "из {n}",
"logModulePlaceholder": "путь.модуля",
"addOverride": "Добавить",
"removeOverride": "Удалить",
"editAsText": "Редактировать как текст",
"editAsChips": "Редактировать как чипы",
"logPreviewLabel": "АКТИВНО",
"unsavedChanges": "Несохранённые изменения",
"unsaved": "НЕ СОХРАНЕНО",
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить",
"release": {
"eyebrow": "Релизы",
"headline": "Следите за обновлениями",
"provider": "Источник",
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
"comingSoon": "Скоро",
"disabled": "Отключено",
"repository": "Репозиторий",
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
"options": "Опции",
"includePrereleases": "Учитывать пре-релизы",
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
"interval": "Интервал проверки",
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
"intervalRange": "1168 ч",
"hoursUnit": "ч",
"testConnection": "Проверить связь",
"checkNow": "Проверить сейчас",
"checkDone": "Проверка релизов завершена",
"checkFailed": "Не удалось проверить релизы",
"testOk": "Источник доступен",
"testFailed": "Источник недоступен",
"testFound": "Найдена версия",
"viewRelease": "Открыть релиз v{v}",
"statusUpToDate": "Актуальная версия",
"statusUpdate": "Доступно обновление",
"statusDisabled": "Проверка релизов отключена",
"statusError": "Ошибка последней проверки",
"statusUnknown": "Ещё не проверялось",
"heroAvailable": "доступна",
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
"lastChecked": "Последняя проверка",
"never": "никогда",
"justNow": "только что",
"minutesAgo": "{n} мин назад",
"hoursAgo": "{n} ч назад",
"daysAgo": "{n} д назад",
"error": {
"disabled": "Проверка релизов отключена",
"misconfigured": "Источник настроен не полностью",
"provider_changed": "Источник изменён — ожидание следующей проверки",
"no_release_found": "Подходящий релиз на источнике не найден",
"network_error": "Источник недоступен",
"http_error": "Источник вернул ошибку",
"parse_error": "Не удалось разобрать ответ источника",
"unsafe_url": "URL отклонён проверкой безопасности",
"not_implemented": "Источник пока не реализован",
"unknown_error": "Неизвестная ошибка",
"error": "Ошибка последней проверки"
}
}
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -818,7 +980,11 @@
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -871,12 +1037,15 @@
"noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
"commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
},
"commandConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -937,9 +1106,12 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
@@ -1012,6 +1184,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1119,6 +1293,12 @@
"memorySourceNative": "Использовать API воспоминаний Immich",
"localeEn": "Английский интерфейс",
"localeRu": "Русский интерфейс",
"logLevelDebug": "Подробный — каждый шаг",
"logLevelInfo": "По умолчанию — ключевые события",
"logLevelWarning": "Только предупреждения и ошибки",
"logLevelError": "Только ошибки — самый тихий",
"logFormatText": "Читаемый человеком текст",
"logFormatJson": "Один JSON-объект на строку",
"modeMedia": "Отправка файлов фото/видео",
"modeText": "Только имена файлов и ссылки",
"allEvents": "Показать все типы событий",
@@ -1130,6 +1310,14 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1312,6 +1500,30 @@
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
"countLabel": "бэкапов",
"scheduleOn": "Авто · каждые {h}ч",
"scheduleOff": "Авто-бэкап выключен",
"lastBackup": "Последний {ago}",
"never": "ещё нет бэкапов",
"totalSize": "всего {size}",
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
"dropZoneActive": "Отпустите для загрузки",
"changeFile": "Сменить файл",
"catGroupIdentity": "Идентичность и маршрутизация",
"catGroupNotif": "Уведомления",
"catGroupCmd": "Команды",
"catGroupSystem": "Система",
"stepCategories": "Что включить",
"stepSecrets": "Обработка секретов",
"stepDownload": "Скачать",
"stepFile": "Выберите файл",
"stepValidate": "Проверить содержимое",
"stepConflict": "При конфликте",
"stepApply": "Применить",
"tagScheduled": "по расписанию",
"tagManual": "вручную",
"tagSecrets": "с секретами",
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Shared locale catalog used by LocaleSelector (settings) and the
* template editors (notification & command). Single source of truth so
* native names and metadata stay consistent across pickers.
*/
export interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
export const LOCALE_CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
export function getLocaleMeta(code: string): LocaleMeta {
return LOCALE_CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
+15
View File
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
desc: () => '',
},
userFilters: [
{
key: 'senders',
label: 'notificationTracker.userAllowlist',
placeholder: 'notificationTracker.selectUsers',
icon: 'mdiAccountCheck',
},
{
key: 'exclude_senders',
label: 'notificationTracker.userBlocklist',
placeholder: 'notificationTracker.selectUsers',
icon: 'mdiAccountOff',
},
],
webhookUrlPattern: '/api/webhooks/gitea/{token}',
};
+21
View File
@@ -120,6 +120,25 @@ export interface CollectionMeta {
desc: (col: any) => string;
}
// ── User-identity filters (TrackerForm) ──────────────────────────────
/**
* Declares a filter that picks user identities from the provider's known
* senders. Rendered as a MultiEntitySelect populated from the provider's
* `/users` endpoint. The picked values are stored as `string[]` under
* `tracker.filters[key]`.
*/
export interface UserFilterMeta {
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
key: string;
/** i18n key for the label rendered above the picker. */
label: string;
/** i18n key for the picker placeholder. */
placeholder: string;
/** MDI icon shown on chips and dropdown rows. */
icon: string;
}
// ── Main descriptor ──────────────────────────────────────────────────
export interface ProviderDescriptor {
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
// ── Collections / Trackers ──
/** Null means this provider has no collections (e.g. scheduler). */
collectionMeta: CollectionMeta | null;
/** Sender allowlist / blocklist pickers shown on the tracker form. */
userFilters?: UserFilterMeta[];
/** Whether this provider is webhook-based (hides scan_interval). */
webhookBased?: boolean;
+75
View File
@@ -20,6 +20,7 @@ import type {
CommandTemplateConfig,
CommandTracker,
Action,
ReleaseStatus,
} from '$lib/types';
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
@@ -112,6 +113,74 @@ 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;
},
};
})();
/** Upstream release status — drives the sidebar badge and Settings cassette. */
export const releaseStatusCache = (() => {
let data = $state<ReleaseStatus | null>(null);
let fetchedAt = $state(0);
let inflight: Promise<ReleaseStatus | null> | null = null;
// 5 min TTL — fresh enough that "Check now" feels instant on revisit,
// long enough that route changes don't hammer the endpoint.
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
clear() {
data = null;
fetchedAt = 0;
inflight = null;
},
set(next: ReleaseStatus | null) {
data = next;
fetchedAt = Date.now();
},
async fetch(force = false): Promise<ReleaseStatus | null> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
data = await api<ReleaseStatus>('/settings/release');
fetchedAt = Date.now();
return data;
} catch {
// Swallow — the badge falls back to its default "no status" state.
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
@@ -164,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
/**
* Invalidate all entity caches. Useful on logout.
*
* Singleton state caches (release status, external URL, supported locales)
* live outside `allCaches` because their shape differs from entity caches
* we clear them explicitly so a returning user as a different role can't
* briefly see the previous user's cached payload.
*/
export function clearAllCaches(): void {
Object.values(allCaches).forEach(c => c.clear());
releaseStatusCache.clear();
}
+67 -1
View File
@@ -106,6 +106,7 @@ export interface NotificationTarget {
name: string;
icon: string;
config: Record<string, any>;
chat_action?: string | null;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
@@ -211,16 +212,51 @@ export interface TemplateConfig {
created_at: string;
}
/**
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
* when a notification doesn't take the immediate-deliver happy path.
*
* * ``deferred`` held back by quiet hours; ``deferred_until`` carries the
* UTC ISO datetime at which a drain job will fire.
* * ``delivered_after_quiet_hours`` a drain successfully dispatched the
* originally-deferred event. ``original_event_log_id`` points back at the
* row from when the event was first detected.
* * ``deferred_then_dropped`` drain time arrived but the link/target was
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
* * ``deferred_then_failed`` drain dispatched but the target returned an
* error; ``reason`` carries the truncated provider error.
* * ``suppressed_quiet_hours_nondeferrable`` wall-clock event type (e.g.
* ``scheduled_message``) caught by quiet hours, dropped on principle.
*/
export type DispatchStatus =
| 'deferred'
| 'delivered_after_quiet_hours'
| 'deferred_then_dropped'
| 'deferred_then_failed'
| 'suppressed_quiet_hours_nondeferrable';
export interface EventLog {
id: number;
event_type: string;
collection_id: string;
collection_name: string;
tracker_id?: number | null;
tracker_name: string;
provider_name: string;
provider_id: number | null;
action_id?: number | null;
action_name?: string;
command_tracker_id?: number | null;
command_tracker_name?: string;
telegram_bot_id?: number | null;
bot_name?: string;
assets_count: number;
details: Record<string, any>;
details: Record<string, any> & {
dispatch_status?: DispatchStatus;
deferred_until?: string;
original_event_log_id?: number | null;
deferred_for_seconds?: number;
};
created_at: string;
}
@@ -337,3 +373,33 @@ export interface DashboardStatus {
recent_events: EventLog[];
command_trackers?: number;
}
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
export interface ReleaseStatus {
provider: ReleaseProviderKind;
current: string;
latest: string | null;
latest_tag: string | null;
latest_url: string | null;
latest_name: string | null;
latest_body: string | null;
latest_published_at: string | null;
latest_prerelease: boolean;
checked_at: string | null;
update_available: boolean;
error: string | null;
}
export interface ReleaseTestResult {
ok: boolean;
info: {
tag: string;
version: string;
name: string | null;
url: string | null;
published_at: string | null;
prerelease: boolean;
} | null;
error: string | null;
}
+91 -13
View File
@@ -18,7 +18,7 @@
providersCache, notificationTrackersCache, trackingConfigsCache,
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
matrixBotsCache, targetsCache,
matrixBotsCache, targetsCache, releaseStatusCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
@@ -31,6 +31,17 @@
let allProviders = $derived(providersCache.items);
// Sidebar release indicator — reads from the cache populated in onMount.
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
// A screen reader hits the brand-version link on every page — keep the
// label informative only when an update is available, otherwise announce
// the version + product so we don't repeat "Up to date" everywhere.
const releaseTooltip = $derived(
releaseUpdateAvailable
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
: `Notify Bridge v${__APP_VERSION__}`
);
let providerFilterItems = $derived([
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
@@ -38,6 +49,11 @@
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
let _syncingFilter = false;
// Reserve the provider-filter row from first paint until the cache resolves.
// Without this, the row appears mid-paint and pushes nav items down on every
// hard reload — the most visible "jump" the user reported.
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
// Sync filter value → store
$effect(() => {
const v = providerFilterValue;
@@ -78,7 +94,24 @@
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
}
let collapsed = $state(false);
// Read persisted UI state synchronously so first paint already matches the
// user's last session — otherwise the sidebar visibly snaps from expanded
// to collapsed (and groups slide open) right after mount.
function readPersistedCollapsed(): boolean {
if (typeof localStorage === 'undefined') return false;
return localStorage.getItem('sidebar_collapsed') === 'true';
}
function readPersistedExpandedGroups(): Record<string, boolean> {
if (typeof localStorage === 'undefined') return {};
try {
const saved = localStorage.getItem('nav_expanded');
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
let collapsed = $state(readPersistedCollapsed());
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
// Nav counts — computed reactively from caches + global provider filter
@@ -216,7 +249,7 @@
};
// Track which groups are expanded (persisted in localStorage)
let expandedGroups = $state<Record<string, boolean>>({});
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
function toggleGroup(key: string) {
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
@@ -262,13 +295,8 @@
onMount(async () => {
initTheme();
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
// `collapsed` and `expandedGroups` are now hydrated synchronously in
// their $state initializers above to avoid a post-mount layout snap.
await loadUser();
if (!auth.user && !isAuthPage) {
redirecting = true;
@@ -289,6 +317,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -384,7 +413,20 @@
{/if}
Notify Bridge
</h1>
<p class="brand-version font-mono">v0.5.2</p>
<p class="brand-version font-mono">
<a
class="brand-version-link"
class:has-update={releaseUpdateAvailable}
href="/settings#release"
aria-label={releaseTooltip}
title={releaseUpdateAvailable ? releaseTooltip : undefined}
>
<span>v{__APP_VERSION__}</span>
{#if releaseUpdateAvailable}
<span class="brand-version-dot" aria-hidden="true"></span>
{/if}
</a>
</p>
</div>
</div>
{:else}
@@ -398,8 +440,10 @@
</button>
</div>
<!-- Global provider filter -->
{#if allProviders.length >= 1}
<!-- Global provider filter — kept rendered during the initial cache
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
push the nav down. Hides only once we confirm zero providers. -->
{#if showProviderFilter}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
@@ -753,6 +797,40 @@
letter-spacing: 0.02em;
font-weight: 500;
}
.brand-version-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
text-decoration: none;
border-radius: 0.3rem;
padding: 1px 4px;
margin: -1px -4px;
transition: color 0.15s, background 0.15s;
}
.brand-version-link:hover {
color: var(--color-foreground);
background: var(--color-glass-strong);
}
.brand-version-link.has-update {
color: var(--color-citrus, #d4a73a);
}
.brand-version-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-citrus, #d4a73a);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
animation: brand-version-pulse 2.4s ease-in-out infinite;
}
@keyframes brand-version-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.brand-version-dot { animation: none; }
.brand-version-link { transition: none; }
}
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
+301 -38
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { goto } from '$app/navigation';
import { api, parseDate } from '$lib/api';
import { requestHighlight } from '$lib/highlight';
import { t } from '$lib/i18n';
import {
providersCache,
@@ -14,12 +16,13 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import type { DashboardStatus } from '$lib/types';
import type { DashboardStatus, EventLog } from '$lib/types';
const SECTIONS_KEY = 'dashboard_section_state';
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
@@ -73,10 +76,57 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
// this whitelist in sync with that helper so a stale localStorage
// value can't smuggle in an unsupported interval (e.g. someone
// hand-edits to 1).
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
function loadRefreshSeconds(): number {
if (typeof localStorage === 'undefined') return 0;
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
const v = stored ? parseInt(stored, 10) : 0;
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
}
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds());
let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade
// (~600ms of fade-up per row) which reads as the panel "reconstructing".
let eventsAnimated = $state(false);
// Auto-refresh ticker — re-creates the interval whenever the user
// changes the cadence. ``$effect`` returns a cleanup that fires on
// destroy AND on any tracked dep change, so the prior timer is torn
// down before a new one starts.
$effect(() => {
if (refreshSeconds <= 0) return;
// Pause auto-refresh when the tab is hidden so we don't burn API
// calls on a tab the user can't see — we'll catch up on the next
// visibility flip via ``visibilitychange`` below.
const tick = () => {
if (typeof document !== 'undefined' && document.hidden) return;
loadEvents({ silent: true });
loadChart();
};
const handle = setInterval(tick, refreshSeconds * 1000);
return () => clearInterval(handle);
});
// Persist whenever the cadence changes (the IconGridSelect mutates
// ``refreshSeconds`` directly via bind:value).
let _refreshHydrated = false;
$effect(() => {
const v = refreshSeconds;
if (!_refreshHydrated) { _refreshHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
});
async function clearEvents() {
try {
@@ -117,22 +167,53 @@
return params;
}
async function loadEvents() {
eventsLoading = true;
/** Reload the events panel.
*
* ``silent`` is set by the auto-refresh ticker so the loading
* placeholder doesn't flash and the row list isn't disturbed when
* nothing actually changed. We diff the new payload against the
* current ``status`` and reuse the existing ``recent_events`` array
* reference when the ID list is identical — that lets Svelte's keyed
* ``{#each}`` skip its diff entirely instead of patching every row.
*/
async function loadEvents(opts: { silent?: boolean } = {}) {
if (!opts.silent) eventsLoading = true;
try {
const params = buildFilterParams();
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
// Nothing changed in the visible page. Update only the
// out-of-band counts so the header and pager stay accurate;
// keep the existing array reference so no row re-renders.
status = {
...status,
providers: next.providers,
trackers: next.trackers,
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
};
return;
}
status = next;
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
eventsLoading = false;
if (!opts.silent) eventsLoading = false;
}
}
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
return true;
}
async function loadChart() {
try {
const params = buildFilterParams();
@@ -202,6 +283,16 @@
}
}
// Disable stagger entry animation once the first non-empty list has
// rendered + had time to play. Subsequent pagination/filter reloads
// then settle in place instead of re-running the cascade.
$effect(() => {
if (eventsAnimated) return;
if (!status?.recent_events?.length) return;
const handle = setTimeout(() => { eventsAnimated = true; }, 700);
return () => clearTimeout(handle);
});
const filteredProviderCount = $derived(globalProviderFilter.providerType
? providers.filter(p => p.type === globalProviderFilter.providerType).length
: displayProviders);
@@ -320,6 +411,19 @@
};
});
function scrollToEvents(e: MouseEvent) {
e.preventDefault();
const el = document.getElementById('events-section');
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function gotoProvider(e: MouseEvent, providerId: number) {
e.preventDefault();
requestHighlight(providerId);
goto('/providers');
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000);
@@ -345,6 +449,9 @@
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
command_handled: 'dashboard.commandHandled',
command_rate_limited: 'dashboard.commandRateLimited',
command_failed: 'dashboard.commandFailed',
};
const eventIcons: Record<string, string> = {
@@ -352,6 +459,7 @@
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
};
// Aurora gradient palette per event type — used for the avatar tile
@@ -365,6 +473,9 @@
action_success: ['var(--color-mint)', 'var(--color-primary)'],
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
};
const STAT_ACCENTS = [
@@ -424,8 +535,8 @@
</section>
<!-- ==================== STATS ==================== -->
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
<div class="stat-card" style="--accent: {card.accent}">
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string; href: string; onclick?: (e: MouseEvent) => void}, idx: number)}
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
<div class="stat-card-inner">
<div class="flex items-center gap-3">
<div class="stat-icon" style="color: {card.accent};">
@@ -439,7 +550,7 @@
</div>
</div>
</div>
</div>
</a>
{/snippet}
{#snippet statCards()}
@@ -452,6 +563,7 @@
value: 0,
literalValue: globalProviderFilter.provider.name,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{:else}
{@render statCardSnippet({
@@ -459,6 +571,7 @@
label: 'dashboard.providers',
value: filteredProviderCount,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{/if}
{@render statCardSnippet({
@@ -467,12 +580,14 @@
value: displayActive,
suffix: ` / ${displayTotal}`,
accent: STAT_ACCENTS[1],
href: '/notification-trackers',
}, 1)}
{@render statCardSnippet({
icon: 'mdiTarget',
label: 'dashboard.targets',
value: displayTargets,
accent: STAT_ACCENTS[2],
href: '/targets',
}, 2)}
{#if status?.command_trackers !== undefined}
{@render statCardSnippet({
@@ -480,6 +595,7 @@
label: 'nav.commandTrackers',
value: displayCommandTrackers,
accent: STAT_ACCENTS[3],
href: '/command-trackers',
}, 3)}
{:else}
{@render statCardSnippet({
@@ -487,6 +603,8 @@
label: 'dashboard.eventsTotal',
value: heroSummary?.throughput ?? 0,
accent: STAT_ACCENTS[3],
href: '#events-section',
onclick: scrollToEvents,
}, 3)}
{/if}
</div>
@@ -496,7 +614,7 @@
<!-- ==================== TWO COL: stream + provider deck ==================== -->
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
<!-- Signal stream -->
<section class="panel">
<section class="panel" id="events-section">
<header class="panel-head">
<div>
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
@@ -532,6 +650,11 @@
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
<IconGridSelect items={refreshIntervalItems()}
bind:value={refreshSeconds}
columns={5} compact />
</div>
</div>
{#snippet paginator()}
@@ -566,17 +689,25 @@
</div>
{/snippet}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else if status.recent_events.length === 0}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{#if status.recent_events.length === 0}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{/if}
{:else}
<div class="signal-list stagger-children">
{#each status.recent_events as event, i}
<div class="signal-row" style="animation-delay: {i * 60}ms;">
<div class="signal-list"
class:stagger-children={!eventsAnimated}
class:signal-list--reloading={eventsLoading}
aria-busy={eventsLoading}>
{#each status.recent_events as event, i (event.id)}
<button type="button" class="signal-row signal-row--clickable"
style={eventsAnimated ? '' : `animation-delay: ${i * 60}ms;`}
onclick={() => selectedEvent = event}
aria-label={t('events.detailTitle')}>
<div class="signal-avatar"
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
@@ -593,7 +724,60 @@
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#if event.tracker_name}
{#if event.details?.dispatch_status === 'deferred' && event.details?.deferred_until}
<span class="dispatch-badge dispatch-badge--deferred"
title={t('dashboard.deferredTitle')}>
<MdiIcon name="mdiPauseCircleOutline" size={12} />
{t('dashboard.heldUntil')} {timeShort(event.details.deferred_until)}
</span>
{:else if event.details?.dispatch_status === 'delivered_after_quiet_hours'}
<span class="dispatch-badge dispatch-badge--late"
title={t('dashboard.deliveredLateTitle')}>
<MdiIcon name="mdiClockCheckOutline" size={12} />
{t('dashboard.deliveredLate')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_dropped'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.deferredThenDroppedTitle')}>
<MdiIcon name="mdiCloseCircleOutline" size={12} />
{t('dashboard.deferredThenDropped')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_failed'}
<span class="dispatch-badge dispatch-badge--dropped"
title={event.details?.reason ?? ''}>
<MdiIcon name="mdiAlertCircleOutline" size={12} />
{t('dashboard.deferredThenFailed')}
</span>
{:else if event.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.suppressedNondeferrableTitle')}>
<MdiIcon name="mdiVolumeOff" size={12} />
{t('dashboard.suppressedQuietHours')}
</span>
{/if}
{#if event.event_type?.startsWith('command_')}
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
{@const issuerLabel = issuer
? (issuer.username ? '@' + issuer.username : [issuer.first_name, issuer.last_name].filter(Boolean).join(' ') || ('id ' + issuer.id))
: ''}
<div class="signal-trail">
{#if event.bot_name}
<span class="ch"><MdiIcon name="mdiRobotHappy" size={11} />{event.bot_name}</span>
{/if}
{#if event.collection_id}
{#if event.bot_name}<span class="arrow"></span>{/if}
<span class="ch"><MdiIcon name="mdiChatProcessing" size={11} />{event.collection_id}</span>
{/if}
{#if issuerLabel}
<span class="arrow"></span>
<span class="ch"><MdiIcon name="mdiAccount" size={11} />{issuerLabel}</span>
{/if}
{#if event.provider_name}
<span class="arrow"></span>
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
{/if}
</div>
{:else if event.tracker_name}
<div class="signal-trail">
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
{#if event.provider_name}
@@ -607,7 +791,7 @@
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
</button>
{/each}
</div>
@@ -646,14 +830,14 @@
{:else}
<div class="provider-deck">
{#each providerDeck as p}
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}">
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}" onclick={(e) => gotoProvider(e, p.id)}>
<div class="provider-icon">
<MdiIcon name={p.icon} size={20} />
</div>
<div class="min-w-0 flex-1">
<div class="provider-name truncate">
<div class="provider-name">
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
{p.name}
<span class="truncate min-w-0">{p.name}</span>
</div>
<div class="provider-sub font-mono">
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
@@ -768,6 +952,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style>
/* ============================================================
HERO
@@ -909,6 +1095,7 @@
============================================================ */
.stat-card {
position: relative;
display: block;
border-radius: 22px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
@@ -918,7 +1105,10 @@
overflow: hidden;
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.stat-card:hover { text-decoration: none; }
.stat-card::before {
content: '';
position: absolute;
@@ -1092,6 +1282,11 @@
SIGNAL STREAM — events with routing trail
============================================================ */
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
/* Soft dim while a page change / filter reload is in flight. We keep
the previous rows mounted (avoids the layout collapsing to a tiny
"Loading…" placeholder) and just nudge opacity so the swap feels
like a refresh rather than a teardown. */
.signal-list--reloading { opacity: 0.55; pointer-events: none; transition: opacity 0.15s ease; }
.signal-row {
display: grid;
grid-template-columns: 40px 1fr auto;
@@ -1103,6 +1298,20 @@
}
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
.signal-row:hover { background: var(--color-glass-strong); }
/* Row is rendered as <button> for clickability — strip default chrome
and align children left like the prior <div> layout. */
.signal-row--clickable {
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: transparent;
border: 0;
}
.signal-row--clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.signal-avatar {
width: 40px; height: 40px;
border-radius: 12px;
@@ -1156,6 +1365,36 @@
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
* Coloured to match the verb (held = primary glow, late = success, drop
* = muted). The icon is intentionally small so the badge doesn't pull
* focus from the event verb itself. */
.dispatch-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
margin-left: 0.4rem;
white-space: nowrap;
}
.dispatch-badge--deferred {
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
}
.dispatch-badge--late {
color: var(--color-success, #16a34a);
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
}
.dispatch-badge--dropped {
color: var(--color-muted-foreground);
opacity: 0.85;
}
.signal-when {
text-align: right;
font-size: 0.7rem;
@@ -1282,6 +1521,7 @@
.provider-meter {
text-align: right;
min-width: 80px;
padding: 4px 4px 4px 0;
}
.provider-num {
font-size: 1rem;
@@ -1295,7 +1535,6 @@
height: 4px;
border-radius: 2px;
background: var(--color-glass-strong);
overflow: hidden;
}
.provider-bar-fill {
height: 100%;
@@ -1315,10 +1554,10 @@
}
.wire {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.85rem;
grid-template-columns: minmax(0, 1fr) minmax(220px, 1.6fr) minmax(0, 1fr);
gap: 1rem;
align-items: center;
padding: 0.7rem 0;
padding: 0.85rem 0;
}
.wire + .wire { border-top: 1px dashed var(--color-border); }
.wire-from, .wire-to {
@@ -1333,26 +1572,50 @@
.wire-sub { font-size: 0.65rem; color: var(--color-muted-foreground); margin-top: 0.15rem; }
.wire-pipe {
position: relative;
min-width: 100px;
display: flex; align-items: center; justify-content: center;
height: 22px;
height: 26px;
}
.wire-pipe::before {
content: '';
position: absolute; inset: 50% 0 auto 0; height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-orchid), var(--color-mint));
opacity: 0.5;
border-radius: 2px;
position: absolute; left: 0; right: 0; top: 50%;
height: 3px; transform: translateY(-50%);
background: linear-gradient(90deg,
color-mix(in srgb, var(--color-primary) 35%, transparent),
var(--color-primary),
var(--color-orchid),
var(--color-mint),
color-mix(in srgb, var(--color-mint) 35%, transparent));
opacity: 0.85;
border-radius: 3px;
box-shadow: 0 0 12px -2px var(--color-glow-strong);
}
.wire-pipe::after {
content: '';
position: absolute; left: 0; right: 0; top: 50%;
height: 1px; transform: translateY(-50%);
background: linear-gradient(90deg, transparent 8%, rgba(255,255,255,0.35) 50%, transparent 92%);
pointer-events: none;
}
.wire-count {
position: relative; z-index: 1;
background: var(--color-glass-elev);
border: 1px solid var(--color-border);
padding: 0.15rem 0.6rem;
border: 1px solid var(--color-rule-strong);
padding: 0.2rem 0.7rem;
border-radius: 999px;
font-size: 0.7rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
box-shadow: 0 2px 10px -4px var(--color-glow), inset 0 1px 0 var(--color-highlight);
}
@media (max-width: 880px) {
.wire {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.wire-to { justify-content: flex-start; text-align: left; }
.wire-pipe { height: 18px; }
}
/* ============================================================
+86 -23
View File
@@ -22,6 +22,7 @@
import ExecutionHistory from './ExecutionHistory.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { Action, ActionRule } from '$lib/types';
let allActions = $derived(actionsCache.items);
@@ -40,7 +41,19 @@
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false,
});
let nameManuallyEdited = $state(false);
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 submitting = $state(false);
let loaded = $state(false);
@@ -98,6 +111,7 @@
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
enabled: false,
};
nameManuallyEdited = false;
editing = null; showForm = true;
}
@@ -109,6 +123,7 @@
schedule_interval: action.schedule_interval,
schedule_cron: action.schedule_cron, enabled: action.enabled,
};
nameManuallyEdited = true;
editing = action.id; showForm = true;
}
@@ -179,13 +194,58 @@
if (status === 'failed') return 'var(--color-error-fg)';
return 'var(--color-muted-foreground)';
}
function statusTone(status: string | undefined): MetaTile['tone'] {
if (status === 'success') return 'mint';
if (status === 'partial') return 'citrus';
if (status === 'failed') return 'coral';
return 'default';
}
function actionTiles(action: Action): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(action.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('commandTracker.disabled'), tone: 'default' });
tiles.push({
icon: 'mdiServer',
label: getProviderName(action.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiTagOutline',
label: action.action_type,
tone: 'sky',
mono: true,
});
tiles.push({
icon: action.schedule_type === 'cron' ? 'mdiClockOutline' : 'mdiTimerOutline',
label: formatSchedule(action),
tone: 'orchid',
mono: true,
});
tiles.push({
icon: 'mdiFormatListBulleted',
value: String(action.rules?.length || 0),
label: t('actions.rules'),
tone: (action.rules?.length || 0) > 0 ? 'sky' : 'default',
});
if (action.last_run_status) {
tiles.push({
icon: 'mdiHistory',
label: action.last_run_status,
tone: statusTone(action.last_run_status),
});
}
return tiles;
}
</script>
<PageHeader
title={t('actions.title')}
emphasis={t('actions.titleEmphasis')}
description={t('actions.description')}
crumb="Routing · Automation"
crumb={t('crumbs.routingAutomation')}
count={actions.length}
countLabel={t('actions.countLabel')}
pills={headerPills}
@@ -245,7 +305,7 @@
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -309,32 +369,35 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each actions as action}
<Card hover entityId={action.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={actionTiles(action)} />
<div class="list-row__actions">
<IconButton icon="mdiPlay" title={t('actions.execute')}
onclick={() => executeAction(action.id)}
disabled={executing[action.id]} />
+47 -12
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -30,8 +31,40 @@
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
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 emailBotTiles(bot: EmailBot): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiEmailOutline',
label: bot.email,
tone: 'lavender',
mono: true,
});
tiles.push({
icon: 'mdiServerNetwork',
label: `${bot.smtp_host}:${bot.smtp_port}`,
tone: 'sky',
mono: true,
});
if (bot.smtp_use_tls) {
tiles.push({
icon: 'mdiLockOutline',
label: 'TLS',
tone: 'mint',
});
}
return tiles;
}
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
@@ -39,6 +72,7 @@
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
nameManuallyEdited = true;
editingEmail = bot.id; showEmailForm = true;
}
@@ -54,7 +88,7 @@
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
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); }
finally { emailSubmitting = false; }
}
@@ -90,7 +124,7 @@
title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={emailBots.length}
countLabel={t('emailBot.countLabel')}
>
@@ -107,7 +141,7 @@
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -156,16 +190,16 @@
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each emailBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
@@ -173,7 +207,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={emailBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
+45 -12
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -29,14 +30,45 @@
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
});
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 matrixBotTiles(bot: MatrixBot): MetaTile[] {
const tiles: MetaTile[] = [];
let host = bot.homeserver_url;
try { host = new URL(bot.homeserver_url).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiServerNetwork',
label: host,
hint: bot.homeserver_url,
href: bot.homeserver_url,
tone: 'lavender',
mono: true,
});
if (bot.display_name) {
tiles.push({
icon: 'mdiAccountCircleOutline',
label: bot.display_name,
tone: 'sky',
});
}
return tiles;
}
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
name: bot.name, icon: bot.icon || '',
homeserver_url: bot.homeserver_url, access_token: '',
display_name: bot.display_name || '',
};
nameManuallyEdited = true;
editingMatrix = bot.id; showMatrixForm = true;
}
@@ -52,7 +84,7 @@
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
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); }
finally { matrixSubmitting = false; }
}
@@ -88,7 +120,7 @@
title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={matrixBots.length}
countLabel={t('matrixBot.countLabel')}
>
@@ -105,7 +137,7 @@
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -139,23 +171,24 @@
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each matrixBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={matrixBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
+201 -71
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
@@ -15,6 +16,7 @@
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
@@ -28,13 +30,25 @@
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: '', icon: '', token: '' });
let nameManuallyEdited = $state(false);
let error = $state('');
let submitting = $state(false);
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
let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
// Distinct from chatsLoading: refresh keeps the existing list visible
// instead of swapping it for a placeholder, avoiding the disorienting
// "everything disappears" flash during Discover.
let chatsRefreshing = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
@@ -47,8 +61,38 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
function telegramBotTiles(bot: TelegramBot): MetaTile[] {
const tiles: MetaTile[] = [];
const mode = bot.update_mode || 'none';
const modeTone: MetaTile['tone'] = mode === 'webhook' ? 'lavender' : mode === 'polling' ? 'mint' : 'default';
const modeLabel = mode === 'webhook' ? t('telegramBot.webhook') : mode === 'polling' ? t('telegramBot.polling') : t('telegramBot.none');
tiles.push({
icon: mode === 'webhook' ? 'mdiWebhook' : mode === 'polling' ? 'mdiSync' : 'mdiPowerOff',
label: modeLabel,
tone: modeTone,
});
if (bot.bot_username) {
tiles.push({
icon: 'mdiAt',
label: bot.bot_username,
tone: 'sky',
mono: true,
});
}
const chatCount = chats[bot.id]?.length;
if (chatCount !== undefined) {
tiles.push({
icon: 'mdiChat',
value: String(chatCount),
label: t('telegramBot.chats'),
tone: chatCount > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; 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) {
e.preventDefault(); error = ''; submitting = true;
@@ -60,7 +104,7 @@
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
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); }
finally { submitting = false; }
}
@@ -98,12 +142,13 @@
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
if (chatsRefreshing[botId]) return;
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
@@ -289,7 +334,7 @@
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
@@ -306,7 +351,7 @@
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -329,18 +374,19 @@
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
</div>
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: (bot.update_mode || 'none') === 'polling'
@@ -348,10 +394,11 @@
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<MetaStrip tiles={telegramBotTiles(bot)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
disabled={chatsLoading[bot.id]}
@@ -371,66 +418,80 @@
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#if chatsLoading[bot.id]}
{#if chatsLoading[bot.id] && !chats[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (chats[bot.id] || []).length === 0}
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
aria-label={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_override || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
</button>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('common.test')} size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
{#if chatsRefreshing[bot.id]}
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
{/if}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
<span style="text-align:center">{t('telegramBot.cmds')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
{/each}
<!-- Rows -->
{#each (chats[bot.id] || []) as chat (chat.id)}
<div style={gridStyle}
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
animate:flip={{ duration: 280 }}
in:fade={{ duration: 220, delay: 60 }}
out:fade={{ duration: 140 }}
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
aria-label={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_override || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
</button>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('common.test')} size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
{/if}
</div>
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
disabled={chatsRefreshing[bot.id]}
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
<MdiIcon name="mdiSync" size={14} />
</span>
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
</button>
</div>
{/if}
@@ -553,3 +614,72 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
/* Chat list — smooth refresh state.
The list stays mounted during Discover; we only dim it slightly
and run a thin shimmer bar across the top so the user sees
"refreshing" instead of "everything vanished and came back". */
.chat-list-wrap {
position: relative;
transition: opacity 0.25s ease, filter 0.25s ease;
}
.chat-list-wrap.is-refreshing {
opacity: 0.78;
filter: saturate(0.9);
}
.chat-list-wrap.is-refreshing .chat-row {
pointer-events: none;
}
.chat-shimmer {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
overflow: hidden;
border-radius: 2px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
z-index: 2;
}
.chat-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
}
@keyframes chat-shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.discover-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.discover-icon.is-spinning {
animation: discover-spin 1s linear infinite;
}
@keyframes discover-spin {
to { transform: rotate(-360deg); }
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer::after,
.discover-icon.is-spinning {
animation: none;
}
.chat-list-wrap {
transition: none;
}
}
</style>
@@ -21,6 +21,8 @@
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -69,6 +71,14 @@
command_template_config_id: null as number | null,
});
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 providerCommands = $derived<{key: string, icon: string}[]>(
@@ -99,6 +109,42 @@
finally { loaded = true; highlightFromUrl(); }
}
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: cfg.provider_type,
tone: 'lavender',
mono: true,
});
const cmdCount = (cfg.enabled_commands || []).length;
tiles.push({
icon: 'mdiSlashForward',
value: String(cmdCount),
label: t('commandConfig.commands'),
tone: cmdCount > 0 ? 'mint' : 'coral',
});
tiles.push({
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
tone: 'sky',
});
tiles.push({
icon: 'mdiNumeric',
value: String(cfg.default_count),
label: t('commandConfig.defaultCount'),
tone: 'citrus',
});
if (cfg.command_template_config_id) {
tiles.push({
icon: 'mdiCodeBracesBox',
label: templateName(cfg.command_template_config_id),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
// Auto-select first provider type with commands
@@ -107,9 +153,31 @@
// Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
nameManuallyEdited = false;
editing = null;
showForm = true;
}
// Re-pick the command-template config when the provider type changes.
// The previously-selected id may belong to a different provider type and
// would no longer appear in the filtered EntitySelect, leaving it empty.
let _prevProviderType = $state('');
$effect(() => {
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
_prevProviderType = form.provider_type;
if (editing === null) {
const currentTpl = cmdTemplateConfigs.find(
(c) => c.id === form.command_template_config_id,
);
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
const first = cmdTemplateConfigs.find(
(c) => c.provider_type === form.provider_type,
);
form.command_template_config_id = first?.id ?? null;
}
}
}
});
function editConfig(cfg: CommandConfig) {
form = {
name: cfg.name,
@@ -121,6 +189,7 @@
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
command_template_config_id: cfg.command_template_config_id ?? null,
};
nameManuallyEdited = true;
editing = cfg.id;
showForm = true;
}
@@ -144,7 +213,7 @@
await api('/command-configs', { method: 'POST', body });
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); }
finally { submitting = false; }
}
@@ -173,7 +242,7 @@
title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('commandConfig.countLabel')}
pills={headerPills}
@@ -193,7 +262,7 @@
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -284,22 +353,20 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as cfg}
<Card hover entityId={cfg.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{cfg.provider_type}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<div class="flex items-center gap-2 mt-0.5 list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)]">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
&middot; {t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</span>
{#if cfg.command_template_config_id}
@@ -307,7 +374,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandConfigTiles(cfg)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
</div>
@@ -20,9 +20,14 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import Hint from '$lib/components/Hint.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
interface CmdTemplateConfig {
id: number;
@@ -41,6 +46,7 @@
}
let LOCALES = $derived(supportedLocalesCache.items);
let primaryLocale = $derived(LOCALES[0] || 'en');
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state('');
@@ -73,7 +79,18 @@
});
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set());
@@ -105,6 +122,14 @@
slots: {} as Record<string, Record<string, string>>,
});
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
let allCapabilities = $state<Record<string, any>>({});
@@ -112,11 +137,40 @@
let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || []
);
let filteredCmdSlots = $derived(
slotFilter
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
: commandSlots
);
const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
/**
* Group command slots by purpose so the form mirrors how notification
* templates are split (event vs scheduled vs settings).
*
* commandResponses — primary reply templates (/start, /help, /status, data slots)
* commandErrors — fallback messages (rate_limited, no_results)
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
* commandUsage — usage_* slots: invocation examples shown by /help
*/
let commandSlotGroups = $derived([
{
group: 'commandResponses',
slots: commandSlots.filter(s =>
!s.name.startsWith('desc_') &&
!s.name.startsWith('usage_') &&
!ERROR_SLOTS.has(s.name)
),
},
{
group: 'commandErrors',
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
},
{
group: 'commandDescriptions',
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
},
{
group: 'commandUsage',
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
},
]);
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
@@ -209,13 +263,52 @@
}
}
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
nameManuallyEdited = false;
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -236,9 +329,10 @@
icon: c.icon || '',
slots: slotsCopy,
};
nameManuallyEdited = true;
editing = c.id;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -332,7 +426,7 @@
};
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -367,7 +461,7 @@
title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
@@ -388,7 +482,7 @@
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -410,89 +504,98 @@
</div>
{/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div class="mb-3">
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<div class="space-y-2">
{#each filteredCmdSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name}{slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<button type="button" onclick={() => togglePreview(slot.name)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div>
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
{@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
{#if filteredSlots.length > 0}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">
{t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
</legend>
<div class="space-y-2 mt-2">
{#each filteredSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name}{slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<button type="button" onclick={() => togglePreview(slot.name)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
</button>
{/if}
{#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
{/if}
{#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else}
<JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={getVarsFor(slot.name) || undefined}
/>
{/if}
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
<JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={getVarsFor(slot.name) || undefined}
/>
{/if}
{/if}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{/if}
{/each}
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
@@ -523,25 +626,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -21,6 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
@@ -61,6 +62,14 @@
enabled: true,
});
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
let filteredConfigs = $derived.by(() => {
@@ -110,9 +119,30 @@
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id;
}
nameManuallyEdited = false;
editing = null;
showForm = true;
}
// Re-pick the command config when the provider changes. The previously
// selected id may belong to a different provider type and would no longer
// appear in the filtered EntitySelect, leaving the selector empty.
let _prevProviderId = $state(0);
$effect(() => {
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id;
if (editing === null) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
if (!currentCfg || currentCfg.provider_type !== ptype) {
const first = commandConfigs.find(c => c.provider_type === ptype);
form.command_config_id = first?.id ?? 0;
}
}
}
}
});
function editTracker(trk: any) {
form = {
name: trk.name,
@@ -121,6 +151,7 @@
command_config_id: trk.command_config_id,
enabled: trk.enabled,
};
nameManuallyEdited = true;
editing = trk.id;
showForm = true;
}
@@ -136,7 +167,7 @@
await api('/command-trackers', { method: 'POST', body });
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); }
finally { submitting = false; }
}
@@ -242,13 +273,39 @@
function configName(id: number): string {
return commandConfigs.find(c => c.id === id)?.name || '?';
}
function commandTrackerTiles(trk: any): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(trk.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiCloseCircle', label: t('commandTracker.disabled'), tone: 'coral' });
tiles.push({
icon: 'mdiServer',
label: providerName(trk.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiCog',
label: configName(trk.command_config_id),
tone: 'sky',
});
if (trk.listener_count !== undefined) {
tiles.push({
icon: 'mdiAccountMultipleOutline',
value: String(trk.listener_count),
label: t('commandTracker.listeners').toLowerCase(),
tone: trk.listener_count > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
</script>
<PageHeader
title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={trackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -268,7 +325,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -311,29 +368,32 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each trackers as trk}
<Card hover entityId={trk.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
{#if trk.listener_count !== undefined}
<span class="text-xs text-[var(--color-muted-foreground)]">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandTrackerTiles(trk)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
@@ -22,6 +22,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
@@ -46,6 +47,7 @@
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
let collections = $state<Record<string, any>[]>([]);
let users = $state<{ id: string; name: string }[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let collectionFilter = $state('');
@@ -69,11 +71,19 @@
filters: {} as Record<string, any>,
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let selectedProviderType = $derived(
providers.find(p => p.id === form.provider_id)?.type || ''
);
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
let expandedTracker = $state<number | null>(null);
let addingTarget = $state<Record<number, boolean>>({});
@@ -167,22 +177,38 @@
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
}
async function loadUsers() {
if (!form.provider_id) { users = []; return; }
// Skip the fetch when the descriptor has no user filters — saves a
// pointless round-trip for providers like Immich/Scheduler.
const desc = getDescriptor(selectedProviderType);
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
try { users = await api(`/providers/${form.provider_id}/users`); }
catch (e) { console.warn('Failed to load users:', e); users = []; }
}
let _prevProviderId = $state(0);
$effect(() => {
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id;
loadCollections();
// Auto-select first available tracking/template config for this provider when creating
loadUsers();
// Re-pick tracking/template configs for the new provider type. The
// previously-selected ids may belong to a different provider type
// and therefore no longer appear in the filtered EntitySelect list,
// which would render the selector as empty.
if (editing === null) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
if (!form.default_tracking_config_id) {
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
if (!currentTc || currentTc.provider_type !== ptype) {
const first = trackingConfigs.find(c => c.provider_type === ptype);
if (first) form.default_tracking_config_id = first.id;
form.default_tracking_config_id = first?.id ?? 0;
}
if (!form.default_template_config_id) {
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
if (!currentTpl || currentTpl.provider_type !== ptype) {
const first = templateConfigs.find(c => c.provider_type === ptype);
if (first) form.default_template_config_id = first.id;
form.default_template_config_id = first?.id ?? 0;
}
}
}
@@ -193,7 +219,8 @@
form = defaultForm();
// Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id;
editing = null; showForm = true; collections = []; previousCollectionIds = [];
nameManuallyEdited = false;
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
}
async function edit(trk: Tracker) {
@@ -207,8 +234,11 @@
filters: trk.filters || {},
};
previousCollectionIds = [...(trk.collection_ids || [])];
nameManuallyEdited = true;
editing = trk.id; showForm = true;
if (form.provider_id) await loadCollections();
if (form.provider_id) {
await Promise.all([loadCollections(), loadUsers()]);
}
}
async function save(e: SubmitEvent) {
@@ -345,6 +375,54 @@
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
}
/**
* Meta tiles for a tracker row. Visible on lg+ in the dead middle space
* between identity and actions. Mirrors the secondary text shown on narrow
* screens, but as live tiles users can scan at a glance.
*/
function trackerTiles(tracker: Tracker): MetaTile[] {
const tiles: MetaTile[] = [];
const trkDesc = getDescriptor(getProviderType(tracker));
// Status — armed/paused with color tone
tiles.push(tracker.enabled
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
// Provider
tiles.push({
icon: 'mdiServer',
label: getProviderName(tracker.provider_id),
tone: 'lavender',
});
// Collections — count + label (varies per provider descriptor)
const collCount = (tracker.collection_ids || []).length;
if (collCount > 0 || !trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiFolderMultipleOutline',
value: String(collCount),
label: getCollectionLabel(tracker),
tone: 'sky',
});
}
// Scan interval — only meaningful for polling trackers
if (!trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiTimerOutline',
value: `${tracker.scan_interval}s`,
label: t('notificationTracker.every').trim(),
tone: 'orchid',
});
}
// Linked targets
const tgtCount = (tracker.tracker_targets || []).length;
tiles.push({
icon: 'mdiTarget',
value: String(tgtCount),
label: t('notificationTracker.linkedTargets'),
tone: tgtCount > 0 ? 'mint' : 'coral',
});
return tiles;
}
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
@@ -439,7 +517,7 @@
title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')}
pills={headerPills}
@@ -460,6 +538,7 @@
bind:form
{providerItems}
{collections}
{users}
bind:collectionFilter
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
@@ -471,6 +550,7 @@
onsave={save}
ontoggleCollection={toggleCollection}
{formatDate}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
@@ -497,24 +577,30 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each notificationTrackers as tracker (tracker.id)}
{@const trkDesc = getDescriptor(getProviderType(tracker))}
<Card hover entityId={tracker.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium truncate">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
<div class="list-row__secondary mt-0.5">
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
</div>
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
<MetaStrip tiles={trackerTiles(tracker)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
@@ -23,6 +23,7 @@
};
providerItems: { value: number; label: string; icon: string; desc: string }[];
collections: any[];
users?: { id: string; name: string }[];
collectionFilter?: string;
trackingConfigItems?: { value: number; label: string; icon: string }[];
templateConfigItems?: { value: number; label: string; icon: string }[];
@@ -34,12 +35,14 @@
onsave: (e: SubmitEvent) => void;
ontoggleCollection?: (collectionId: string) => void;
formatDate?: (dateStr: string) => string;
onnameinput?: () => void;
}
let {
form = $bindable(),
providerItems,
collections,
users = [],
collectionFilter = $bindable(),
trackingConfigItems = [],
templateConfigItems = [],
@@ -51,6 +54,7 @@
onsave,
ontoggleCollection,
formatDate,
onnameinput,
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
@@ -93,7 +97,7 @@
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2">
<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>
@@ -116,6 +120,21 @@
</div>
{/if}
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
{#each descriptor.userFilters as uf (uf.key)}
<div>
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[uf.key] || []}
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
placeholder={t(uf.placeholder)}
/>
</div>
{/each}
{/if}
{#if isScheduler}
<!-- Schedule type -->
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
@@ -208,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
</div>
</div>
{/if}
+134 -26
View File
@@ -3,7 +3,7 @@
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
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 Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -21,10 +21,11 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.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 { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
import type { ServiceProvider } from '$lib/types';
@@ -45,6 +46,91 @@
let confirmDelete = $state<ServiceProvider | null>(null);
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;
}
/**
* Build meta tiles for a provider row. Filled into the dead middle space
* on wide displays; on narrow screens the secondary text line takes over.
*/
function providerTiles(provider: ServiceProvider): MetaTile[] {
const tiles: MetaTile[] = [];
const h = health[provider.id];
const provDesc = getDescriptor(provider.type);
// Status — first tile, color-coded
if (h === true) {
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
} else if (h === false) {
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
} else {
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
}
// Type / connection address
const cfg = provider.config as Record<string, any> | undefined;
if (cfg?.url) {
tiles.push({
icon: 'mdiLinkVariant',
label: shortenUrl(cfg.url),
hint: cfg.url,
href: cfg.url,
tone: 'sky',
mono: true,
});
} else if (cfg?.host) {
tiles.push({
icon: 'mdiServer',
label: `${cfg.host}:${cfg.port || 3493}`,
tone: 'sky',
mono: true,
});
}
// Webhook URL (copy to clipboard)
if (provDesc?.webhookUrlPattern) {
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
tiles.push({
icon: 'mdiContentCopy',
label: t('providers.webhookUrl'),
hint: webhookUrl,
tone: 'orchid',
onclick: (e) => copyWebhookUrl(e, webhookUrl),
});
}
return tiles;
}
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
function shortenUrl(url: string): string {
try {
const u = new URL(url);
const segments = u.pathname.split('/').filter(Boolean);
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
return `${u.host}${tail}`;
} catch {
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
}
}
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)
$effect(() => {
@@ -76,6 +162,7 @@
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
});
onDestroy(() => topbarAction.clear());
async function load() {
@@ -173,7 +260,7 @@
title={t('providers.title')}
emphasis={t('providers.titleEmphasis')}
description={t('providers.description')}
crumb="Service · Connections"
crumb={t('crumbs.serviceConnections')}
count={providers.length}
countLabel={t('dashboard.providersShort')}
pills={headerPills}
@@ -197,7 +284,7 @@
{/if}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<div in:slide={{ duration: 200 }} class="list-stack">
<Card class="mb-6">
<ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3">
@@ -246,9 +333,15 @@
</div>
{/each}
{#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="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>
</div>
{/if}
@@ -261,9 +354,11 @@
{/if}
{#if !showForm && allProviders.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="list-stack mb-3">
<div class="flex items-center gap-2">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{/if}
@@ -276,30 +371,43 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
</div>
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
<div class="list-row__secondary">
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@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}
</div>
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#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>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={providerTiles(provider)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
</div>
+176 -194
View File
@@ -2,17 +2,19 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
import SettingsHero from './SettingsHero.svelte';
import IdentityCassette from './IdentityCassette.svelte';
import TelegramCassette from './TelegramCassette.svelte';
import ReleaseCassette from './ReleaseCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import SaveBar from './SaveBar.svelte';
interface CacheBucketStats {
count: number;
@@ -25,12 +27,24 @@
asset: CacheBucketStats;
}
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state({
interface Settings {
external_url: string;
telegram_webhook_secret: string;
telegram_cache_ttl_hours: string;
telegram_asset_cache_max_entries: string;
supported_locales: string;
timezone: string;
log_level: string;
log_format: string;
log_levels: string;
release_provider_kind: string;
release_provider_url: string;
release_provider_repo: string;
release_include_prereleases: string;
release_check_interval_hours: string;
}
const EMPTY: Settings = {
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '720',
@@ -40,10 +54,38 @@
log_level: 'INFO',
log_format: 'text',
log_levels: '',
});
release_provider_kind: 'gitea',
release_provider_url: 'https://git.dolgolyov-family.by',
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
release_include_prereleases: '0',
release_check_interval_hours: '12',
};
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state<Settings>({ ...EMPTY });
// Snapshot of the last server-known state, used for dirty tracking.
let baseline = $state<Settings>({ ...EMPTY });
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
// --- Dirty tracking -----------------------------------------------------
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
const out: Array<keyof Settings> = [];
for (const key of Object.keys(settings) as Array<keyof Settings>) {
if (settings[key] !== baseline[key]) out.push(key);
}
return out;
});
const dirty = $derived(dirtyKeys.length > 0);
// --- Data loading -------------------------------------------------------
async function loadCacheStats(): Promise<void> {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
@@ -51,211 +93,151 @@
onMount(async () => {
try {
settings = await api('/settings');
const fetched = await api<Settings>('/settings');
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; }
// Warm the release status so the cassette renders the strip on first paint.
await releaseStatusCache.fetch();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
snackError(msg);
} finally {
loaded = true;
}
});
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
// --- Actions ------------------------------------------------------------
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
async function save(): Promise<void> {
saving = true;
error = '';
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
const next = await api<Settings>('/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
settings = { ...EMPTY, ...next };
baseline = { ...settings };
externalUrlCache.invalidate();
// Release config may have changed → drop the cached status and
// refetch so the sidebar badge + cassette strip reflect the
// freshly-rescheduled probe without waiting for the next route
// change to trigger another read.
releaseStatusCache.invalidate();
void releaseStatusCache.fetch(true);
snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
error = msg;
snackError(msg);
} finally {
saving = false;
}
}
async function clearTelegramCache() {
function discard(): void {
settings = { ...baseline };
}
async function clearTelegramCache(): Promise<void> {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Clear cache failed';
snackError(msg);
} finally {
clearingCache = false;
}
}
const cacheMaxEntriesNum = $derived(
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
);
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
<SettingsHero {settings} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
<div class="space-y-6">
<!-- General section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiCog" size={18} />
{t('settings.general')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div>
</Card>
<!-- Telegram section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiSend" size={18} />
{t('settings.telegram')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div>
</Card>
<div class="settings-page stagger-children">
<IdentityCassette
bind:externalUrl={settings.external_url}
bind:timezone={settings.timezone}
bind:supportedLocales={settings.supported_locales}
/>
<!-- Locales section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTranslate" size={18} />
{t('settings.locales')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<LocaleSelector bind:value={settings.supported_locales} />
</div>
</div>
</Card>
<div class="telegram-deck">
<TelegramCassette
bind:webhookSecret={settings.telegram_webhook_secret}
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
/>
<CacheLedger
stats={cacheStats}
clearing={clearingCache}
maxEntries={cacheMaxEntriesNum}
onRefresh={loadCacheStats}
onClear={() => (confirmClearCache = true)}
/>
</div>
<!-- Logging section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTextBoxOutline" size={18} />
{t('settings.logging')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<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>
<ReleaseCassette
bind:providerKind={settings.release_provider_kind}
bind:providerUrl={settings.release_provider_url}
bind:providerRepo={settings.release_provider_repo}
bind:includePrereleases={settings.release_include_prereleases}
bind:checkIntervalHours={settings.release_check_interval_hours}
/>
<Button onclick={save} disabled={saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
bind:logLevels={settings.log_levels}
/>
</div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
<SaveBar
{dirty}
{saving}
changedCount={dirtyKeys.length}
onSave={save}
onDiscard={discard}
/>
{/if}
<ConfirmModal
open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => (confirmClearCache = false)}
/>
<style>
.settings-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.telegram-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.telegram-deck { grid-template-columns: 1fr 1fr; }
}
</style>
@@ -0,0 +1,406 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import Hint from '$lib/components/Hint.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>
<Hint text={t('settings.cacheStatsHint')} />
{/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>
@@ -0,0 +1,698 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import { api } from '$lib/api';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
interface Props {
// All five fields are persisted as strings via the /settings PUT —
// the parent owns the boundary type. Bool flags use "0" / "1".
providerKind: string;
providerUrl: string;
providerRepo: string;
includePrereleases: string;
checkIntervalHours: string;
}
let {
providerKind = $bindable(),
providerUrl = $bindable(),
providerRepo = $bindable(),
includePrereleases = $bindable(),
checkIntervalHours = $bindable(),
}: Props = $props();
let checking = $state(false);
let testing = $state(false);
let testResult = $state<ReleaseTestResult | null>(null);
const status = $derived(releaseStatusCache.value);
const prereleaseChecked = $derived(includePrereleases === '1');
const isDisabled = $derived(providerKind === 'disabled');
// Stale Test-result on input change is misleading — wipe whenever any of
// the probed parameters change so the strip reflects "current" state.
$effect(() => {
// Touch each parameter to register dependency.
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
testResult = null;
});
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
const stateTone: Tone = $derived.by(() => {
if (!status) return 'sky';
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
if (status.update_available) return 'citrus';
if (status.provider === 'disabled') return 'sky';
return 'mint';
});
const stateLabel = $derived.by(() => {
if (!status) return t('settings.release.statusUnknown');
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
if (status.update_available) return t('settings.release.statusUpdate');
if (status.latest) return t('settings.release.statusUpToDate');
return t('settings.release.statusUnknown');
});
// Map backend error taxonomy → localized text. Falls back to the raw code
// only when the key is missing (so a new server code surfaces something).
function localizedError(code: string | null): string {
if (!code) return '';
const key = `settings.release.error.${code}`;
const localized = t(key);
// `t` falls back to the key itself when missing — detect by exact match.
return localized === key ? code : localized;
}
function relTime(iso: string | null): string {
if (!iso) return t('settings.release.never');
const then = Date.parse(iso);
if (!Number.isFinite(then)) return t('settings.release.never');
const diff = Date.now() - then;
const min = Math.round(diff / 60_000);
if (min < 1) return t('settings.release.justNow');
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
const h = Math.round(min / 60);
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
const d = Math.round(h / 24);
return t('settings.release.daysAgo').replace('{n}', String(d));
}
function setProvider(kind: ReleaseProviderKind): void {
providerKind = kind;
}
function onIntervalInput(e: Event): void {
// The native input emits string values; we keep the contract by
// re-coercing to string before assigning to the bindable prop.
const raw = (e.currentTarget as HTMLInputElement).value;
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
}
async function checkNow(): Promise<void> {
checking = true;
try {
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
releaseStatusCache.set(next);
snackSuccess(t('settings.release.checkDone'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
} finally {
checking = false;
}
}
async function testProvider(): Promise<void> {
testing = true;
testResult = null;
try {
testResult = await api<ReleaseTestResult>('/settings/release/test', {
method: 'POST',
body: JSON.stringify({
provider_kind: providerKind,
provider_url: providerUrl,
provider_repo: providerRepo,
include_prereleases: prereleaseChecked,
}),
});
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
else snackError(t('settings.release.testFailed'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
} finally {
testing = false;
}
}
</script>
<section class="rel glass" id="release">
<header class="rel-head">
<div class="rel-eyebrow">
<MdiIcon name="mdiUpdate" size={12} />
<span>{t('settings.release.eyebrow')}</span>
</div>
<h3 class="rel-title">{t('settings.release.headline')}</h3>
</header>
<div class="rel-body">
<!-- 01 Provider — native radios for free keyboard a11y. -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<span class="row-name">
{t('settings.release.provider')}
<Hint text={t('settings.release.providerHint')} />
</span>
</div>
<div class="row-control">
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
<input
type="radio"
name="release-provider"
value="gitea"
checked={providerKind === 'gitea'}
onchange={() => setProvider('gitea')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
</label>
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
<input
type="radio"
name="release-provider"
value="github"
disabled
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
</label>
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
<input
type="radio"
name="release-provider"
value="disabled"
checked={providerKind === 'disabled'}
onchange={() => setProvider('disabled')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
</label>
</div>
</div>
</div>
<!-- 02 Repository -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.release.repository')}
<Hint text={t('settings.release.repositoryHint')} />
</span>
</div>
<div class="row-control repo-grid">
<input
bind:value={providerUrl}
placeholder="https://git.example.com"
class="text-input"
type="url"
spellcheck="false"
disabled={isDisabled}
/>
<input
bind:value={providerRepo}
placeholder="owner/repo"
class="text-input mono"
spellcheck="false"
disabled={isDisabled}
/>
</div>
</div>
<!-- 03 Options — slider toggle for include-prereleases. -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.release.options')}
<Hint text={t('settings.release.prereleasesHint')} />
</span>
</div>
<div class="row-control">
<button
type="button"
class="toggle"
class:toggle-disabled={isDisabled}
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
aria-pressed={prereleaseChecked}
disabled={isDisabled}
>
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
</button>
</div>
</div>
<!-- 04 Check interval -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">04</span>
<span class="row-name">
{t('settings.release.interval')}
<Hint text={t('settings.release.intervalHint')} />
</span>
</div>
<div class="row-control interval">
<input
type="number"
min={1}
max={168}
value={checkIntervalHours}
oninput={onIntervalInput}
class="text-input num"
disabled={isDisabled}
/>
<span class="unit">{t('settings.release.hoursUnit')}</span>
<span class="footnote">{t('settings.release.intervalRange')}</span>
</div>
</div>
</div>
<!-- State strip -->
<footer class="strip" data-tone={stateTone}>
<div class="strip-left">
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
<div class="strip-text">
<div class="strip-state">{stateLabel}</div>
<div class="strip-meta">
<span class="versions">
<span class="v-current">v{status?.current ?? '—'}</span>
{#if status?.latest && status.latest !== status.current}
<span class="arrow" aria-hidden="true"></span>
<span
class="v-latest"
class:v-latest-update={status.update_available}
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
{/if}
</span>
<span class="sep" aria-hidden="true">·</span>
<span class="checked">
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
</span>
</div>
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
</div>
{/if}
{#if testResult && !testResult.ok}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
{localizedError(testResult.error)}
</div>
{/if}
{#if testResult && testResult.ok && testResult.info}
<div class="strip-test-ok">
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
<span class="mono">v{testResult.info.version}</span>
</div>
{/if}
</div>
</div>
<div class="strip-actions">
{#if status?.update_available && status.latest_url}
<a
class="strip-btn strip-btn-cta"
href={status.latest_url}
target="_blank"
rel="noopener noreferrer"
>
<MdiIcon name="mdiOpenInNew" size={13} />
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
</a>
{/if}
<button
type="button"
class="strip-btn"
onclick={testProvider}
disabled={testing || isDisabled || !providerRepo}
>
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
<span>{t('settings.release.testConnection')}</span>
</button>
<button
type="button"
class="strip-btn strip-btn-primary"
onclick={checkNow}
disabled={checking || isDisabled}
>
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
<span>{t('settings.release.checkNow')}</span>
</button>
</div>
</footer>
</section>
<style>
.rel {
padding: 1.5rem 1.6rem 0;
display: flex;
flex-direction: column;
gap: 1.2rem;
overflow: hidden;
}
.rel-head { position: relative; z-index: 1; }
.rel-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;
}
.rel-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;
}
.rel-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-dim { opacity: 0.55; }
.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; }
/* Segmented provider control — uses real radios so arrow-key + tab
navigation just work via the browser. */
.seg {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
}
.seg-item {
display: inline-flex;
align-items: center;
border-radius: 0.45rem;
cursor: pointer;
position: relative;
}
.seg-radio {
position: absolute;
opacity: 0;
pointer-events: none;
inset: 0;
}
.seg-radio:focus-visible + .seg-content {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.seg-content {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 0.45rem;
font-size: 0.78rem;
color: var(--color-muted-foreground);
transition: background 0.18s, color 0.18s;
}
.seg-item:hover:not(.seg-soon) .seg-content {
color: var(--color-foreground);
background: var(--color-glass);
}
.seg-active .seg-content {
color: var(--color-foreground);
background: var(--color-input-bg);
box-shadow: 0 0 0 1px var(--color-primary);
}
.seg-soon { opacity: 0.45; cursor: not-allowed; }
/* Text fields */
.repo-grid {
display: grid;
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
gap: 0.6rem;
max-width: 100%;
}
.text-input {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
background: var(--color-input-bg);
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--color-foreground);
transition: border-color 0.18s, box-shadow 0.18s;
}
.text-input.mono { font-family: var(--font-mono); }
.text-input.num { max-width: 6rem; text-align: right; }
.text-input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* Interval */
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.unit {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footnote {
font-size: 0.68rem;
color: var(--color-muted-foreground);
font-style: italic;
}
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
.toggle {
display: inline-flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: var(--color-foreground);
cursor: pointer;
}
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
.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-text { font-size: 0.82rem; }
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
/* State strip */
.strip {
margin: 0 -1.6rem;
padding: 1rem 1.6rem;
border-top: 1px solid var(--color-border);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
transparent
);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
position: relative;
}
.strip[data-tone="citrus"]::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 10%,
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
transparent 90%
);
animation: aurora-shimmer 4s linear infinite;
}
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
margin-top: 0.45rem;
flex-shrink: 0;
}
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.strip-state {
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
}
.strip-meta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.74rem;
color: var(--color-muted-foreground);
}
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
.arrow { color: var(--color-muted-foreground); }
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
.sep { opacity: 0.5; }
.rel-time { color: var(--color-foreground); }
.strip-error {
font-size: 0.72rem;
color: var(--color-coral, #d27a7a);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-test-ok {
font-size: 0.72rem;
color: var(--color-mint, #6fcfa6);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
.strip-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.55rem;
background: var(--color-input-bg);
font-size: 0.76rem;
color: var(--color-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.18s, border-color 0.18s, transform 0.18s;
}
.strip-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
border-color: var(--color-primary);
}
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.strip-btn-primary {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
}
/* The CTA — high-visibility when an update is available. */
.strip-btn-cta {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
color: var(--color-foreground);
font-weight: 500;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
}
.strip-btn-cta:hover {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
}
.mono { font-family: var(--font-mono); }
@keyframes aurora-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.strip[data-tone="citrus"]::before { animation: none; }
.strip-btn { transition: none; }
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
.repo-grid { grid-template-columns: 1fr; }
.strip { flex-direction: column; align-items: stretch; }
.strip-actions { justify-content: stretch; }
.strip-btn { flex: 1; justify-content: center; }
}
</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,108 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
import { releaseStatusCache } from '$lib/stores/caches.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',
});
const rs = releaseStatusCache.value;
if (rs) {
if (rs.provider === 'disabled') {
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
} else if (rs.error && rs.error !== 'provider_changed') {
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
} else if (rs.update_available && rs.latest) {
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
} else if (rs.latest) {
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
}
}
return out;
});
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb={t('crumbs.systemConfiguration')}
{pills}
/>
@@ -0,0 +1,344 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
interface Props {
webhookSecret: string;
cacheTtlHours: string;
cacheMaxEntries: string;
}
let {
webhookSecret = $bindable(),
cacheTtlHours = $bindable(),
cacheMaxEntries = $bindable(),
}: Props = $props();
let showSecret = $state(false);
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
const ttlHours = $derived(Number(cacheTtlHours || '0'));
const ttlIsOff = $derived(ttlHours <= 0);
function ttlHumanized(h: number): string {
if (h <= 0) return t('settings.ttlNoExpiry');
if (h < 24) return `${h}h`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d`;
const w = Math.round(d / 7);
if (w < 8) return `${w}w`;
const mo = Math.round(d / 30);
return `${mo}mo`;
}
</script>
<section class="tg glass">
<header class="tg-head">
<div class="tg-eyebrow">
<MdiIcon name="mdiSend" size={12} />
<span>{t('settings.telegram')}</span>
</div>
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
</header>
<div class="tg-grid">
<!-- Webhook secret column -->
<div class="col">
<div class="col-head">
<span class="col-num">A</span>
<span class="col-name">
{t('settings.webhookSecret')}
<Hint text={t('settings.webhookSecretHint')} />
</span>
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
<span class="dot"></span>
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
</span>
</div>
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input
bind:value={webhookSecret}
type={showSecret ? 'text' : 'password'}
autocomplete="off"
placeholder={t('providers.optional')}
class="secret-input"
/>
<button
type="button"
class="secret-toggle"
onclick={() => (showSecret = !showSecret)}
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
title={showSecret ? t('settings.hide') : t('settings.show')}
>
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
</button>
</form>
</div>
<!-- Cache config column -->
<div class="col">
<div class="col-head">
<span class="col-num">B</span>
<span class="col-name">{t('settings.cacheConfig')}</span>
</div>
<div class="cache-grid">
<label class="num-field">
<span class="num-label">
{t('settings.cacheTtlShort')}
<Hint text={t('settings.cacheTtlHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheTtlHours}
type="number"
min="0"
max="8760"
class="num-input"
/>
<span class="num-suffix">{t('settings.hoursShort')}</span>
</div>
<span class="num-meta" class:num-meta-off={ttlIsOff}>
{ttlHumanized(ttlHours)}
</span>
</label>
<label class="num-field">
<span class="num-label">
{t('settings.cacheMaxShort')}
<Hint text={t('settings.cacheMaxEntriesHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheMaxEntries}
type="number"
min="100"
max="100000"
class="num-input"
/>
<span class="num-suffix">{t('settings.entriesShort')}</span>
</div>
<span class="num-meta">
{t('settings.cacheMaxFootnote')}
</span>
</label>
</div>
</div>
</div>
</section>
<style>
.tg {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
min-height: 100%;
}
.tg-head {
position: relative;
z-index: 1;
}
.tg-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.tg-title {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.tg-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 720px) {
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
}
.col {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.col-head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.col-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
text-transform: uppercase;
}
.col-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
display: inline-flex;
align-items: center;
}
.col-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
}
.col-status .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-muted-foreground);
}
.col-status[data-state="set"] {
color: var(--color-mint);
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
}
.col-status[data-state="set"] .dot {
background: var(--color-mint);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
}
/* --- Secret field --- */
.secret-field {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
background: var(--color-input-bg);
transition: border-color 0.18s, box-shadow 0.18s;
}
.secret-field:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.secret-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
padding: 0.5rem 0.4rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-foreground);
min-width: 0;
letter-spacing: 0.05em;
}
.secret-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.secret-toggle:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
/* --- Cache config grid --- */
.cache-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
.num-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.7rem 0.85rem 0.65rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.num-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
.num-row {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.num-input {
width: 100%;
padding: 0.1rem 0;
border: 0;
background: transparent;
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--color-foreground);
letter-spacing: -0.015em;
line-height: 1.1;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.num-suffix {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.num-meta {
font-size: 0.7rem;
color: var(--color-mint);
font-family: var(--font-mono);
}
.num-meta-off {
color: var(--color-citrus);
}
@media (max-width: 480px) {
.cache-grid { grid-template-columns: 1fr; }
}
</style>
+261 -400
View File
@@ -2,8 +2,6 @@
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -11,32 +9,55 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
// --- Export state ---
let exportSecrets = $state('exclude');
let exporting = $state(false);
import BackupHero from './BackupHero.svelte';
import PendingStrip from './PendingStrip.svelte';
import ExportPanel from './ExportPanel.svelte';
import ImportPanel from './ImportPanel.svelte';
import ScheduleCassette from './ScheduleCassette.svelte';
import BackupLedger from './BackupLedger.svelte';
const categories = [
{ key: 'providers', label: 'backup.catProviders' },
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
{ key: 'email_bots', label: 'backup.catEmailBots' },
{ key: 'targets', label: 'backup.catTargets' },
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
{ key: 'actions', label: 'backup.catActions' },
{ key: 'app_settings', label: 'backup.catAppSettings' },
type SecretsMode = 'exclude' | 'masked' | 'include';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
const allCategories = [
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
'tracking_configs', 'template_configs',
'command_configs', 'command_template_configs',
'notification_trackers', 'command_trackers',
'actions', 'app_settings',
];
// --- Export state ---
let exportSecrets = $state<SecretsMode>('exclude');
let exporting = $state(false);
let selectedCategories = $state<Record<string, boolean>>(
Object.fromEntries(categories.map(c => [c.key, true]))
Object.fromEntries(allCategories.map(k => [k, true]))
);
// --- Import state ---
let importFile: File | null = $state(null);
let importConflict = $state('skip');
let importConflict = $state<ConflictMode>('skip');
let importing = $state(false);
let validating = $state(false);
let validationResult: any = $state(null);
@@ -47,7 +68,7 @@
// --- Scheduled backup state ---
let loaded = $state(false);
let error = $state('');
let scheduledSettings = $state({
let scheduledSettings = $state<ScheduledSettings>({
backup_scheduled_enabled: 'false',
backup_scheduled_interval_hours: '24',
backup_secrets_mode: 'exclude',
@@ -56,22 +77,22 @@
let savingSchedule = $state(false);
// --- Backup files ---
let backupFiles = $state<any[]>([]);
let backupFiles = $state<BackupFile[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let pending = $state<PendingState | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
api<ScheduledSettings>('/backup/scheduled'),
api<BackupFile[]>('/backup/files'),
api<PendingState>('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
@@ -84,7 +105,7 @@
}
});
async function cancelPending() {
async function cancelPending(): Promise<void> {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
@@ -92,14 +113,13 @@
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
async function applyAndRestart(): Promise<void> {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
@@ -117,7 +137,7 @@
}
}
async function createManualBackup() {
async function createManualBackup(): Promise<void> {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
@@ -132,7 +152,7 @@
}
// --- Export ---
async function doExport() {
async function doExport(): Promise<void> {
if (exportSecrets === 'include') {
confirmExportOpen = true;
return;
@@ -140,7 +160,7 @@
await performExport();
}
async function performExport() {
async function performExport(): Promise<void> {
confirmExportOpen = false;
exporting = true;
try {
@@ -165,8 +185,14 @@
}
}
// --- Validate ---
async function validateFile() {
// --- Validate / Import ---
function handleFileSelect(file: File | null): void {
importFile = file;
validationResult = null;
importResult = null;
}
async function validateFile(): Promise<void> {
if (!importFile) return;
validating = true;
validationResult = null;
@@ -183,12 +209,11 @@
}
}
// --- Import ---
async function doImport() {
function doImport(): void {
confirmImportOpen = true;
}
async function performImport() {
async function performImport(): Promise<void> {
confirmImportOpen = false;
if (!importFile) return;
importing = true;
@@ -213,10 +238,10 @@
}
// --- Scheduled settings ---
async function saveSchedule() {
async function saveSchedule(): Promise<void> {
savingSchedule = true;
try {
scheduledSettings = await api('/backup/scheduled', {
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
method: 'PUT',
body: JSON.stringify(scheduledSettings),
});
@@ -229,10 +254,10 @@
}
// --- File management ---
async function refreshFiles() {
async function refreshFiles(): Promise<void> {
loadingFiles = true;
try {
backupFiles = await api('/backup/files');
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} finally {
@@ -240,7 +265,7 @@
}
}
async function downloadFile(filename: string) {
async function downloadFile(filename: string): Promise<void> {
try {
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -255,7 +280,7 @@
}
}
async function deleteFile(filename: string) {
async function deleteFile(filename: string): Promise<void> {
try {
await api(`/backup/files/${filename}`, { method: 'DELETE' });
snackSuccess(t('backup.fileDeleted'));
@@ -265,355 +290,61 @@
snackError(err.message);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.length) {
importFile = input.files[0];
validationResult = null;
importResult = null;
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
function toggleAll() {
const newVal = !allSelected;
for (const key of Object.keys(selectedCategories)) {
selectedCategories[key] = newVal;
}
}
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
<div class="backup-page stagger-children">
<div class="action-deck">
<ExportPanel
{selectedCategories}
{exportSecrets}
{exporting}
onCategoriesChange={(next) => selectedCategories = next}
onSecretsChange={(next) => exportSecrets = next}
onExport={doExport}
/>
<ImportPanel
{importFile}
{importConflict}
{validating}
{validationResult}
{importing}
{importResult}
onFileSelect={handleFileSelect}
onConflictChange={(mode) => importConflict = mode}
onValidate={validateFile}
onImport={doImport}
/>
</div>
{/if}
<div class="space-y-6">
<ScheduleCassette
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
bind:secretsMode={scheduledSettings.backup_secrets_mode}
bind:retentionCount={scheduledSettings.backup_retention_count}
saving={savingSchedule}
onToggle={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
onSave={saveSchedule}
/>
<!-- Export Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseExport" size={18} />
{t('backup.export')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
<!-- Categories -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium">{t('backup.categories')}</span>
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{#each categories as cat}
<label class="flex items-center gap-1.5 text-xs">
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
{t(cat.label)}
</label>
{/each}
</div>
</div>
<!-- Secrets mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="exclude" />
{t('backup.secretsExclude')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="masked" />
{t('backup.secretsMasked')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="include" />
{t('backup.secretsInclude')}
</label>
</div>
{#if exportSecrets === 'include'}
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiAlert" size={14} />
{t('backup.secretsWarningExport')}
</div>
{/if}
</div>
<Button onclick={doExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</Card>
<!-- Import Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseImport" size={18} />
{t('backup.import')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
<!-- File picker -->
<div class="mb-4">
<input type="file" accept=".json" onchange={handleFileSelect}
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
</div>
{#if importFile}
<!-- Validate -->
<div class="mb-4 flex items-center gap-2">
<Button variant="secondary" onclick={validateFile} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckCircleOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
</div>
{#if validationResult}
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="flex items-center gap-2 mb-2 font-medium">
{#if validationResult.valid}
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
{:else}
<MdiIcon name="mdiCloseCircle" size={14} />
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
{/if}
</div>
{#if Object.keys(validationResult.entity_counts || {}).length}
<div class="mb-2">
<span class="font-medium">{t('backup.entities')}:</span>
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
<span class="inline-block mr-2">{cat}: {count}</span>
{/each}
</div>
{/if}
{#each validationResult.warnings || [] as w}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
<MdiIcon name="mdiAlert" size={12} />
<span>{w}</span>
</div>
{/each}
{#each validationResult.errors || [] as e}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={12} />
<span>{e}</span>
</div>
{/each}
</div>
{/if}
<!-- Conflict mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="skip" />
{t('backup.conflictSkip')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="rename" />
{t('backup.conflictRename')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="overwrite" />
{t('backup.conflictOverwrite')}
</label>
</div>
</div>
<Button onclick={doImport}
disabled={importing || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="font-medium mb-1">{t('backup.importResults')}</div>
<div class="space-y-0.5">
<div>{t('backup.resultCreated')}: {importResult.created}</div>
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
{#if importResult.errors?.length}
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
{#each importResult.errors as e}
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
{/each}
{/if}
{#if importResult.warnings?.length}
{#each importResult.warnings as w}
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
{/each}
{/if}
</div>
</div>
{/if}
{/if}
</Card>
<!-- Scheduled Backups Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiClockOutline" size={18} />
{t('backup.scheduled')}
</h3>
<div class="space-y-3">
<label class="flex items-center gap-2 text-xs">
<input type="checkbox"
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
onchange={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
<span class="font-medium">{t('backup.enableScheduled')}</span>
</label>
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="6">6 {t('backup.hours')}</option>
<option value="12">12 {t('backup.hours')}</option>
<option value="24">24 {t('backup.hours')}</option>
<option value="48">48 {t('backup.hours')}</option>
<option value="72">72 {t('backup.hours')}</option>
<option value="168">168 {t('backup.hours')} (7d)</option>
</select>
</div>
<div>
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="exclude">{t('backup.secretsExclude')}</option>
<option value="masked">{t('backup.secretsMasked')}</option>
<option value="include">{t('backup.secretsInclude')}</option>
</select>
</div>
<div>
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="3">3</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
{/if}
</div>
<div class="mt-4">
<Button onclick={saveSchedule} disabled={savingSchedule}>
{savingSchedule ? t('common.loading') : t('common.save')}
</Button>
</div>
</Card>
<!-- Saved Backup Files -->
<Card>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold flex items-center gap-2">
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
</div>
</div>
{#if backupFiles.length === 0}
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
{:else}
<div class="space-y-2">
{#each backupFiles as file}
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
style="border-color: var(--color-border);">
<div class="flex items-center gap-2">
<MdiIcon name="mdiFileDocument" size={14} />
<span class="font-mono">{file.filename}</span>
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
</div>
<div class="flex items-center gap-1">
<button onclick={() => downloadFile(file.filename)}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button onclick={() => confirmDeleteFile = file.filename}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
style="color: var(--color-error-fg);">
<MdiIcon name="mdiDelete" size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</Card>
<BackupLedger
files={backupFiles}
loading={loadingFiles}
creating={creatingBackup}
onCreate={createManualBackup}
onRefresh={refreshFiles}
onDownload={downloadFile}
onDelete={(filename) => confirmDeleteFile = filename}
/>
</div>
{/if}
@@ -652,27 +383,25 @@
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
class="post-restore-card"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<div class="post-restore-head">
<div class="post-restore-icon">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
<div class="post-restore-text">
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
<p>{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
<div class="post-restore-actions">
<button class="post-restore-later" type="button"
onclick={() => postRestoreModalOpen = false}>
{t('backup.applyLater')}
</button>
{#if pending.supervised}
@@ -687,30 +416,162 @@
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<div class="restart-overlay" role="alert" aria-live="assertive">
<div class="restart-card">
<div class="restart-spinner">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
<p class="restart-title">{t('backup.restartingTitle')}</p>
<p class="restart-sub">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.backup-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.action-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.action-deck { grid-template-columns: 1fr 1fr; }
}
/* Post-restore modal */
.post-restore-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.post-restore-card {
background: var(--color-glass-elev);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-rule-strong);
border-radius: 22px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
}
.post-restore-head {
display: flex;
align-items: flex-start;
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.post-restore-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-warning-bg);
color: var(--color-warning-fg);
flex-shrink: 0;
}
.post-restore-text { min-width: 0; }
.post-restore-text h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 500;
font-size: 1.15rem;
margin: 0 0 0.25rem;
letter-spacing: -0.015em;
}
.post-restore-text p {
font-size: 0.82rem;
color: var(--color-muted-foreground);
margin: 0;
line-height: 1.45;
word-wrap: break-word;
}
.post-restore-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.post-restore-later {
padding: 0 0.95rem;
height: 34px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.post-restore-later:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
/* Restarting overlay */
.restart-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.restart-card {
text-align: center;
padding: 1.6rem 2rem;
color: var(--color-foreground);
}
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-bottom: 0.85rem;
color: var(--color-primary);
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
.restart-title {
font-family: var(--font-display);
font-style: italic;
font-size: 1.2rem;
font-weight: 500;
margin: 0;
letter-spacing: -0.015em;
}
.restart-sub {
font-size: 0.8rem;
color: var(--color-muted-foreground);
margin: 0.4rem 0 0;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.restart-spinner { animation: none !important; }
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface Props {
files: BackupFile[];
scheduled: ScheduledSettings;
pending: { pending: boolean } | null;
}
let { files, scheduled, pending }: Props = $props();
function relativeTime(iso: string | null | undefined): string {
if (!iso) return '';
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function latestCreatedAt(list: BackupFile[]): string | null {
const stamps = list
.map(f => f.created_at)
.filter((s): s is string => !!s)
.sort();
return stamps.length ? stamps[stamps.length - 1] : null;
}
function ageHours(iso: string | null): number {
if (!iso) return Infinity;
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return Infinity;
return (Date.now() - date.getTime()) / 3_600_000;
}
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
const out: Array<{ label: string; tone?: Tone }> = [];
if (pending?.pending) {
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
}
if (scheduled.backup_scheduled_enabled === 'true') {
out.push({
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
tone: 'mint',
});
} else {
out.push({ label: t('backup.scheduleOff') });
}
const latest = latestCreatedAt(files);
if (latest) {
const hours = ageHours(latest);
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
} else {
out.push({ label: t('backup.never'), tone: 'citrus' });
}
return out;
});
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb={t('crumbs.systemMaintenance')}
count={files.length}
countLabel={t('backup.countLabel')}
{pills}
/>
@@ -0,0 +1,357 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
interface Props {
files: BackupFile[];
loading: boolean;
creating: boolean;
onCreate: () => void;
onRefresh: () => void;
onDownload: (filename: string) => void;
onDelete: (filename: string) => void;
}
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseDate(iso: string | null | undefined): Date | null {
if (!iso) return null;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? null : d;
}
function relativeTime(iso: string | null | undefined): string {
const date = parseDate(iso);
if (!date) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function absoluteTime(iso: string | null | undefined): string {
const date = parseDate(iso);
return date ? date.toLocaleString() : '—';
}
function ageTone(iso: string | null | undefined): Tone {
const date = parseDate(iso);
if (!date) return 'coral';
const hours = (Date.now() - date.getTime()) / 3_600_000;
if (hours < 48) return 'mint';
if (hours < 24 * 7) return 'sky';
if (hours < 24 * 30) return 'citrus';
return 'coral';
}
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
</script>
<section class="ledger glass">
<header class="ledger-head">
<div>
<div class="ledger-eyebrow">
<MdiIcon name="mdiArchiveOutline" size={12} />
<span>{t('backup.savedFiles')}</span>
</div>
{#if files.length > 0}
<div class="ledger-summary">
<span class="ledger-count font-mono">{files.length}</span>
<span class="ledger-count-label">{t('backup.countLabel')}</span>
<span class="ledger-sep">·</span>
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
</div>
{/if}
</div>
<div class="ledger-actions">
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
{#if creating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiPlus" size={14} />
{/if}
{creating ? t('common.loading') : t('backup.createManual')}
</Button>
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
</button>
</div>
</header>
{#if files.length === 0}
<div class="ledger-empty">
<MdiIcon name="mdiCloudOffOutline" size={28} />
<p>{t('backup.noFiles')}</p>
</div>
{:else}
<ol class="ledger-list">
{#each files as file (file.filename)}
{@const tone = ageTone(file.created_at)}
<li class="row" data-tone={tone}>
<span class="row-edge" aria-hidden="true"></span>
<span class="row-dot" aria-hidden="true"></span>
<div class="row-time">
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
<span class="row-abs" title={absoluteTime(file.created_at)}>
{absoluteTime(file.created_at)}
</span>
</div>
<div class="row-name">
<span class="row-filename" title={file.filename}>{file.filename}</span>
</div>
<span class="row-size font-mono">{formatBytes(file.size)}</span>
<div class="row-actions">
<button class="icon-btn" type="button"
onclick={() => onDownload(file.filename)}
aria-label={t('backup.download')}
title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button class="icon-btn icon-btn-danger" type="button"
onclick={() => onDelete(file.filename)}
aria-label={t('common.delete')}
title={t('common.delete')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
</button>
</div>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.ledger {
padding: 1.4rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.95rem;
}
.ledger-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ledger-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.3rem;
}
.ledger-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
line-height: 1;
}
.ledger-count {
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.ledger-count-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
.ledger-total {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.ledger-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn-danger:hover:not(:disabled) {
color: var(--color-error-fg);
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
}
.spinning {
display: inline-flex;
animation: ledger-spin 1.1s linear infinite;
}
@keyframes ledger-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ledger-empty {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.6rem 1rem;
color: var(--color-muted-foreground);
text-align: center;
}
.ledger-empty p { margin: 0; font-size: 0.8rem; }
.ledger-list {
position: relative;
z-index: 1;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
position: relative;
display: grid;
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
transition: transform 0.18s, border-color 0.18s, background 0.18s;
overflow: hidden;
}
.row:hover {
transform: translateY(-1px);
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.row-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
.row-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
.row-time {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 6.5rem;
}
.row-rel {
font-size: 0.78rem;
color: var(--color-foreground);
font-weight: 500;
letter-spacing: -0.005em;
}
.row-abs {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
}
.row-name {
min-width: 0;
}
.row-filename {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row:hover .row-filename { color: var(--color-foreground); }
.row-size {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-align: right;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.15rem;
opacity: 0;
transition: opacity 0.18s;
}
.row:hover .row-actions,
.row:focus-within .row-actions { opacity: 1; }
@media (max-width: 640px) {
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
.row-time { grid-column: 2; min-width: 0; }
.row-name { grid-column: 1 / -1; }
.row-size { grid-column: 3; grid-row: 1; }
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.row { transition: none !important; }
.row:hover { transform: none !important; }
.spinning { animation: none !important; }
}
</style>
@@ -0,0 +1,392 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type SecretsMode = 'exclude' | 'masked' | 'include';
interface Props {
selectedCategories: Record<string, boolean>;
exportSecrets: SecretsMode;
exporting: boolean;
onCategoriesChange: (next: Record<string, boolean>) => void;
onSecretsChange: (next: SecretsMode) => void;
onExport: () => void;
}
let {
selectedCategories,
exportSecrets,
exporting,
onCategoriesChange,
onSecretsChange,
onExport,
}: Props = $props();
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
{
key: 'identity',
labelKey: 'backup.catGroupIdentity',
icon: 'mdiAccountNetwork',
cats: [
{ key: 'providers', labelKey: 'backup.catProviders' },
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
{ key: 'targets', labelKey: 'backup.catTargets' },
],
},
{
key: 'notif',
labelKey: 'backup.catGroupNotif',
icon: 'mdiBellOutline',
cats: [
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
],
},
{
key: 'cmd',
labelKey: 'backup.catGroupCmd',
icon: 'mdiConsoleLine',
cats: [
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
],
},
{
key: 'system',
labelKey: 'backup.catGroupSystem',
icon: 'mdiCog',
cats: [
{ key: 'actions', labelKey: 'backup.catActions' },
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
],
},
];
function toggleCat(key: string): void {
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
}
function groupState(groupKey: string): 'all' | 'none' | 'some' {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return 'none';
const flags = group.cats.map(c => !!selectedCategories[c.key]);
if (flags.every(v => v)) return 'all';
if (flags.every(v => !v)) return 'none';
return 'some';
}
function toggleGroup(groupKey: string): void {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return;
const target = groupState(groupKey) !== 'all';
const next = { ...selectedCategories };
for (const c of group.cats) next[c.key] = target;
onCategoriesChange(next);
}
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
];
</script>
<section class="export-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseExport" size={14} />
<span>{t('backup.export')}</span>
</div>
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: categories -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepCategories')}</span>
<span class="step-count">{totalSelected}</span>
</div>
<div class="group-grid">
{#each categoryGroups as group}
{@const state = groupState(group.key)}
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
<span class="group-title">{t(group.labelKey)}</span>
<span class="group-state">
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
</span>
</button>
<div class="chip-row">
{#each group.cats as cat}
<button class="chip" type="button"
class:chip-on={selectedCategories[cat.key]}
onclick={() => toggleCat(cat.key)}>
{t(cat.labelKey)}
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Step 2: secrets -->
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepSecrets')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
{#each secretsModes as mode}
<button type="button"
role="radio"
aria-checked={exportSecrets === mode.value}
class="seg"
class:seg-on={exportSecrets === mode.value}
onclick={() => onSecretsChange(mode.value)}>
<MdiIcon name={mode.icon} size={14} />
<span>{t(mode.labelKey)}</span>
</button>
{/each}
</div>
{#if exportSecrets === 'include'}
<div class="warn-strip" role="status">
<span class="warn-edge" aria-hidden="true"></span>
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
<span>{t('backup.secretsWarningExport')}</span>
</div>
{/if}
</div>
<!-- Step 3: CTA -->
<div class="step step-cta">
<Button onclick={onExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</div>
</div>
</section>
<style>
.export-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head {
position: relative;
z-index: 1;
}
.panel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.step-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
}
.step-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.group-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.55rem;
}
@media (min-width: 560px) {
.group-grid { grid-template-columns: 1fr 1fr; }
}
.group {
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
padding: 0.55rem 0.65rem 0.7rem;
transition: border-color 0.18s ease, background 0.18s ease;
}
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
.group-head {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: 0;
padding: 0.15rem 0.1rem 0.4rem;
cursor: pointer;
color: var(--color-foreground);
font-family: inherit;
}
.group-icon { color: var(--color-primary); display: inline-flex; }
.group-title {
font-size: 0.74rem;
font-weight: 500;
letter-spacing: -0.005em;
flex: 1;
text-align: left;
}
.group-state {
display: inline-flex;
color: var(--color-muted-foreground);
}
.group-all .group-state { color: var(--color-primary); }
.group-some .group-state { color: var(--color-citrus); }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.chip {
font-size: 0.7rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
.chip-on {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
color: var(--color-foreground);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.warn-strip {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 10px;
font-size: 0.72rem;
line-height: 1.4;
color: var(--color-error-fg);
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
overflow: hidden;
}
.warn-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--color-coral);
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
}
</style>
@@ -0,0 +1,603 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface ValidationResult {
valid: boolean;
entity_counts?: Record<string, number>;
warnings?: string[];
errors?: string[];
}
interface ImportResult {
created?: number;
skipped?: number;
overwritten?: number;
errors?: string[];
warnings?: string[];
}
interface Props {
importFile: File | null;
importConflict: ConflictMode;
validating: boolean;
validationResult: ValidationResult | null;
importing: boolean;
importResult: ImportResult | null;
onFileSelect: (file: File | null) => void;
onConflictChange: (mode: ConflictMode) => void;
onValidate: () => void;
onImport: () => void;
}
let {
importFile,
importConflict,
validating,
validationResult,
importing,
importResult,
onFileSelect,
onConflictChange,
onValidate,
onImport,
}: Props = $props();
let dragging = $state(false);
let inputEl = $state<HTMLInputElement | undefined>();
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
];
function pickFile(): void {
inputEl?.click();
}
function handleInput(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
onFileSelect(file);
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files?.[0];
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
onFileSelect(file);
}
}
function handleDragOver(e: DragEvent): void {
e.preventDefault();
dragging = true;
}
function handleDragLeave(): void {
dragging = false;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const entityCount = $derived(
validationResult?.entity_counts
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
: 0
);
</script>
<section class="import-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseImport" size={14} />
<span>{t('backup.import')}</span>
</div>
<h3 class="panel-title">{t('backup.importDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: file -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepFile')}</span>
</div>
{#if importFile}
<div class="file-pill">
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
<div class="file-meta">
<div class="file-name" title={importFile.name}>{importFile.name}</div>
<div class="file-size">{formatBytes(importFile.size)}</div>
</div>
<button class="file-change" type="button" onclick={pickFile}>
<MdiIcon name="mdiSwapHorizontal" size={14} />
<span>{t('backup.changeFile')}</span>
</button>
</div>
{:else}
<button type="button"
class="dropzone"
class:dropzone-active={dragging}
onclick={pickFile}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}>
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
<span class="dropzone-text">
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
</span>
</button>
{/if}
<input bind:this={inputEl} type="file" accept=".json,application/json"
class="visually-hidden" onchange={handleInput} />
</div>
<!-- Step 2: validate -->
{#if importFile}
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepValidate')}</span>
{#if validationResult}
<span class="validate-pill"
class:validate-ok={validationResult.valid}
class:validate-bad={!validationResult.valid}>
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
</span>
{/if}
</div>
{#if !validationResult}
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
{:else}
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
{#if entityCount > 0}
<div class="validate-summary">
<span class="validate-count font-mono">{entityCount}</span>
<span class="validate-count-label">{t('backup.entities')}</span>
</div>
<div class="validate-categories">
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
<span class="validate-cat">
<span class="validate-cat-num font-mono">{count}</span>
<span class="validate-cat-name">{cat}</span>
</span>
{/each}
</div>
{/if}
{#if validationResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each validationResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
{#if validationResult.errors?.length}
<ul class="validate-list validate-err">
{#each validationResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Step 3: conflict mode -->
{#if importFile && validationResult?.valid}
<div class="step">
<div class="step-head">
<span class="step-num">03</span>
<span class="step-label">{t('backup.stepConflict')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
{#each conflictOptions as opt}
<button type="button"
role="radio"
aria-checked={importConflict === opt.value}
class="seg"
class:seg-on={importConflict === opt.value}
onclick={() => onConflictChange(opt.value)}>
<MdiIcon name={opt.icon} size={14} />
<span>{t(opt.labelKey)}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Step 4: CTA + results -->
<div class="step step-cta">
{#if importFile && !validationResult?.valid && !validating}
<div class="cta-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('backup.validateFirst')}</span>
</div>
{/if}
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="import-results">
<div class="result-tiles">
<div class="result-tile tile-created">
<span class="result-num font-mono">{importResult.created ?? 0}</span>
<span class="result-label">{t('backup.resultCreated')}</span>
</div>
<div class="result-tile tile-skipped">
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
<span class="result-label">{t('backup.resultSkipped')}</span>
</div>
<div class="result-tile tile-overwritten">
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
<span class="result-label">{t('backup.resultOverwritten')}</span>
</div>
</div>
{#if importResult.errors?.length}
<ul class="validate-list validate-err">
{#each importResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
{#if importResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each importResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
</section>
<style>
.import-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head { position: relative; z-index: 1; }
.panel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step { display: flex; flex-direction: column; gap: 0.6rem; }
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
}
/* Drop zone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 1.65rem 1.1rem;
border-radius: 16px;
border: 1.5px dashed var(--color-rule-strong);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
text-align: center;
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
min-height: 140px;
}
.dropzone:hover {
color: var(--color-foreground);
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.dropzone-active {
color: var(--color-foreground);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
transform: scale(1.005);
}
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
.dropzone-text { line-height: 1.4; max-width: 28ch; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* File pill */
.file-pill {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.file-icon { color: var(--color-primary); flex-shrink: 0; }
.file-meta { flex: 1; min-width: 0; }
.file-name {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.66rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.file-change {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.32rem 0.65rem;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.7rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
/* Validation */
.validate-pill {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-weight: 500;
}
.validate-ok {
color: var(--color-success-fg);
background: var(--color-success-bg);
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
}
.validate-bad {
color: var(--color-error-fg);
background: var(--color-error-bg);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
}
.validate-card {
padding: 0.7rem 0.85rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.validate-card-bad {
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
}
.validate-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
}
.validate-count {
font-size: 1.4rem;
font-weight: 500;
color: var(--color-foreground);
line-height: 1;
}
.validate-count-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.validate-categories {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.validate-cat {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass);
font-size: 0.66rem;
}
.validate-cat-num {
color: var(--color-primary);
font-weight: 500;
}
.validate-cat-name {
color: var(--color-muted-foreground);
}
.validate-list {
list-style: none;
padding: 0; margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.validate-list li {
display: flex;
align-items: flex-start;
gap: 0.35rem;
font-size: 0.7rem;
line-height: 1.4;
}
.validate-warn li { color: var(--color-warning-fg); }
.validate-err li { color: var(--color-error-fg); }
/* Segmented (same vocabulary as ExportPanel) */
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.cta-hint {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.65rem;
}
.import-results {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.result-tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
}
.result-tile {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.6rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.result-num {
font-size: 1.2rem;
font-weight: 500;
line-height: 1;
color: var(--color-foreground);
}
.result-label {
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted-foreground);
}
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
.tile-created .result-num { color: var(--color-mint); }
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
.tile-skipped .result-num { color: var(--color-sky); }
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
.tile-overwritten .result-num { color: var(--color-citrus); }
</style>
@@ -0,0 +1,136 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
interface Props {
pending: PendingState | null;
onApply: () => void;
onCancel: () => void;
}
let { pending, onApply, onCancel }: Props = $props();
</script>
{#if pending?.pending}
<div class="pending-strip animate-rise" role="alert">
<span class="pending-edge" aria-hidden="true"></span>
<span class="aurora-pulse error" aria-hidden="true"></span>
<div class="pending-body">
<div class="pending-title">
<MdiIcon name="mdiShieldAlertOutline" size={16} />
<span>{t('backup.pendingTitle')}</span>
</div>
<div class="pending-meta">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
<span class="pending-dot">·</span>
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
</div>
</div>
<div class="pending-actions">
{#if pending.supervised}
<Button size="sm" onclick={onApply}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button class="pending-cancel" onclick={onCancel} type="button">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<style>
.pending-strip {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
margin-bottom: 1.25rem;
border-radius: 18px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
box-shadow:
var(--shadow-card),
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
overflow: hidden;
flex-wrap: wrap;
}
.pending-strip::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.35;
}
.pending-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
}
.pending-body {
position: relative;
z-index: 1;
flex: 1;
min-width: 12rem;
}
.pending-title {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.01em;
}
.pending-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
margin-top: 0.18rem;
word-break: break-word;
}
.pending-dot {
opacity: 0.6;
margin: 0 0.25rem;
}
.pending-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.pending-cancel {
padding: 0 0.95rem;
height: 34px;
font-size: 0.82rem;
border-radius: 12px;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.pending-cancel:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
enabled: boolean;
intervalHours: string;
secretsMode: string;
retentionCount: string;
saving: boolean;
onToggle: () => void;
onSave: () => void;
}
let {
enabled,
intervalHours = $bindable(),
secretsMode = $bindable(),
retentionCount = $bindable(),
saving,
onToggle,
onSave,
}: Props = $props();
const intervalItems: GridItem[] = $derived([
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
]);
const secretsItems: GridItem[] = $derived([
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
]);
const retentionItems: GridItem[] = $derived([
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
]);
</script>
<section class="cassette glass" class:cassette-on={enabled}>
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
<span class="toggle-track" class:toggle-on={enabled}>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">
<span class="cassette-eyebrow">
<MdiIcon name="mdiClockOutline" size={12} />
<span>{t('backup.scheduled')}</span>
</span>
<span class="cassette-title">{t('backup.enableScheduled')}</span>
</span>
</button>
{#if enabled}
<div class="cassette-controls">
<div class="ctl">
<span class="ctl-label">{t('backup.interval')}</span>
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.secretsMode')}</span>
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.retention')}</span>
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
</div>
</div>
{:else}
<div class="cassette-off">{t('backup.scheduleOff')}</div>
{/if}
<div class="cassette-save">
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
<MdiIcon name="mdiContentSave" size={14} />
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
</section>
<style>
.cassette {
display: flex;
align-items: stretch;
gap: 1.1rem;
padding: 0.95rem 1.15rem;
flex-wrap: wrap;
}
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
.cassette-toggle {
display: flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
cursor: pointer;
font-family: inherit;
color: var(--color-foreground);
text-align: left;
padding: 0.2rem 0.1rem;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
.cassette-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.cassette-title {
font-size: 0.85rem;
font-weight: 500;
font-family: var(--font-display);
font-style: italic;
letter-spacing: -0.005em;
color: var(--color-foreground);
}
.cassette-controls {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem;
flex: 1;
min-width: 0;
}
@media (min-width: 720px) {
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.ctl-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.cassette-off {
flex: 1;
display: flex;
align-items: center;
font-size: 0.78rem;
color: var(--color-muted-foreground);
font-style: italic;
font-family: var(--font-display);
position: relative;
z-index: 1;
}
.cassette-save {
display: flex;
align-items: flex-end;
position: relative;
z-index: 1;
flex-shrink: 0;
}
@media (max-width: 720px) {
.cassette-save { width: 100%; }
.cassette-save > :global(*) { width: 100%; }
}
</style>
+390 -55
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
@@ -13,7 +15,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -22,6 +24,7 @@
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
@@ -92,6 +95,53 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
tiles.push({
icon: TYPE_ICONS[target.type] || 'mdiTarget',
label: target.type,
tone: 'lavender',
mono: true,
});
const botName = getBotName(target);
if (botName) {
tiles.push({
icon: 'mdiRobot',
label: botName,
tone: 'sky',
});
}
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
icon: 'mdiChat',
label: String(cfg.chat_id),
tone: 'orchid',
mono: true,
});
}
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiLinkVariant',
label: host,
hint: String(cfg.url),
href: String(cfg.url),
tone: 'orchid',
mono: true,
});
}
return tiles;
}
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
@@ -129,6 +179,7 @@
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let error = $state('');
let loaded = $state(false);
let submitting = $state(false);
@@ -137,6 +188,17 @@
let confirmDelete = $state<NotificationTarget | null>(null);
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() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -152,6 +214,20 @@
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// Per-target expansion state for the receivers section. Hidden by default.
let expandedTargets = $state<Set<number>>(new SvelteSet());
function isExpanded(id: number): boolean {
return expandedTargets.has(id);
}
function toggleExpanded(id: number) {
if (expandedTargets.has(id)) expandedTargets.delete(id);
else expandedTargets.add(id);
}
function expandTarget(id: number) {
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// Reset form when switching target type tabs
@@ -167,6 +243,98 @@
onMount(load);
// ── Bot grouping ──
type TargetGroup = {
key: string;
type: string;
name: string;
subtitle: string | null;
icon: string;
typeBadge: string | null;
botHref: string | null;
botEntityId: number | null;
muted: boolean;
targets: NotificationTarget[];
};
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
const groupedTargets = $derived.by<TargetGroup[]>(() => {
const groups = new Map<string, TargetGroup>();
for (const tgt of targets) {
const isBotType = BOT_TYPES.has(tgt.type);
const botId = isBotType ? getBotEntityId(tgt) : null;
const key = isBotType
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
: `${tgt.type}:direct`;
let group = groups.get(key);
if (!group) {
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
let name = '';
let subtitle: string | null = null;
let muted = false;
if (isBotType && botId) {
if (tgt.type === 'telegram') {
const bot = telegramBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
icon = bot?.icon || 'mdiSend';
} else if (tgt.type === 'email') {
const bot = emailBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.email || null;
icon = bot?.icon || 'mdiEmailOutline';
} else if (tgt.type === 'matrix') {
const bot = matrixBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.display_name || bot?.homeserver_url || null;
icon = bot?.icon || 'mdiMatrix';
}
} else if (isBotType) {
name = t('targets.groupNoBot');
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
muted = true;
} else {
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
subtitle = t('targets.groupDirect');
muted = true;
}
group = {
key,
type: tgt.type,
name,
subtitle,
icon,
typeBadge,
botHref: isBotType && botId ? getBotHref(tgt) : null,
botEntityId: isBotType ? botId : null,
muted,
targets: [],
};
groups.set(key, group);
}
group.targets.push(tgt);
}
const rank = (g: TargetGroup) => {
if (g.type === 'broadcast') return 4;
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
return 1; // bot-linked
};
return [...groups.values()].sort((a, b) => {
const ra = rank(a), rb = rank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name);
});
});
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
@@ -204,6 +372,16 @@
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
async function discoverReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
function openNew() {
@@ -213,6 +391,7 @@
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 === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
nameManuallyEdited = false;
editing = null;
showTelegramSettings = false;
showForm = true;
@@ -229,7 +408,7 @@
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
@@ -242,6 +421,7 @@
// broadcast
child_target_ids: c.child_target_ids || [],
};
nameManuallyEdited = true;
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
@@ -268,7 +448,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
@@ -284,10 +464,12 @@
config = { child_target_ids: form.child_target_ids };
}
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
if (formType === 'telegram') body.chat_action = form.chat_action || null;
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
}
showForm = false;
editing = null;
@@ -325,15 +507,27 @@
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
if (addingReceiverForTarget === targetId) {
addingReceiverForTarget = null;
await tick();
}
addingReceiverForTarget = targetId;
expandTarget(targetId);
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
// Show what we have immediately (cached list), then actively discover in the
// background so any newly-added chats appear in the palette as soon as
// Telegram returns them.
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
if (botId) {
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
discoverReceiverBotChats(botId);
}
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
@@ -437,7 +631,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
crumb={t('crumbs.routingTargets')}
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
@@ -474,6 +668,7 @@
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
@@ -493,53 +688,85 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.type === 'broadcast' && target.child_targets?.length}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list -->
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
<div class="targets-list">
{#each groupedTargets as group (group.key)}
<section class="target-group">
<BotGroupHeader
icon={group.icon}
name={group.name}
subtitle={group.subtitle}
targetCount={group.targets.length}
typeBadge={!activeType ? group.typeBadge : null}
botHref={group.botHref}
botEntityId={group.botEntityId}
muted={group.muted}
/>
</Card>
<div class="target-group__items stagger-children">
{#each group.targets as target (target.id)}
{@const expanded = isExpanded(target.id)}
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center 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>
<MetaStrip tiles={targetTiles(target)} />
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list (collapsible) -->
{#if expanded}
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</div>
{/if}
</Card>
{/each}
</div>
</section>
{/each}
</div>
{/if}
@@ -561,3 +788,111 @@
/>
<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 1 auto;
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;
}
@media (min-width: 1024px) {
.target-summary {
flex: 0 1 auto;
max-width: 32rem;
}
}
.target-summary:hover {
background: var(--color-glass-strong);
}
.target-summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.target-summary__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
}
.target-summary__chevron.open {
transform: rotate(90deg);
color: var(--color-primary);
}
.target-summary__icon {
color: var(--color-primary);
display: inline-flex;
flex-shrink: 0;
}
.target-summary__name {
font-weight: 500;
font-size: 0.95rem;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.target-summary__count {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.12rem 0.45rem;
border-radius: 9999px;
background: var(--color-muted);
flex-shrink: 0;
}
.target-summary__count-num {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-foreground);
}
.target-summary__count-label {
text-transform: lowercase;
}
.target-summary__count--empty {
font-style: italic;
font-family: inherit;
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: transparent;
padding: 0.12rem 0.2rem;
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts">
import MdiIcon from '$lib/components/MdiIcon.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { t } from '$lib/i18n';
interface Props {
icon: string;
name: string;
subtitle?: string | null;
targetCount: number;
typeBadge?: string | null;
botHref?: string | null;
botEntityId?: number | null;
muted?: boolean;
}
let {
icon,
name,
subtitle = null,
targetCount,
typeBadge = null,
botHref = null,
botEntityId = null,
muted = false,
}: Props = $props();
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
</script>
<div class="bot-group-header" class:muted>
<div class="bot-avatar">
<MdiIcon name={icon} size={18} />
</div>
<div class="bot-meta">
<div class="bot-title-row">
<span class="bot-name">{name}</span>
{#if typeBadge}
<span class="type-badge">{typeBadge}</span>
{/if}
</div>
{#if subtitle}
<span class="bot-sub">{subtitle}</span>
{/if}
</div>
<div class="bot-actions">
<span class="count-chip">
<span class="count-num">{targetCount}</span>
<span class="count-label">{countLabel}</span>
</span>
{#if botHref}
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
{/if}
</div>
</div>
<style>
.bot-group-header {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
margin: 1.4rem 0 0.55rem 0;
border-radius: 14px;
background: linear-gradient(
95deg,
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
var(--color-glass) 75%
);
border: 1px solid var(--color-rule-strong);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
overflow: hidden;
}
.bot-group-header::before {
content: '';
position: absolute;
left: 0;
top: 12%;
bottom: 12%;
width: 3px;
border-radius: 0 4px 4px 0;
background: linear-gradient(
180deg,
var(--color-primary),
color-mix(in srgb, var(--color-primary) 35%, transparent)
);
}
.bot-group-header.muted {
background: var(--color-glass);
}
.bot-group-header.muted::before {
background: var(--color-rule-strong);
}
.bot-avatar {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.muted .bot-avatar {
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border-color: var(--color-rule-strong);
}
.bot-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.bot-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.bot-name {
font-size: 0.92rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.type-badge {
font-size: 0.6rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--color-muted);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.bot-sub {
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bot-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.count-chip {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.18rem 0.5rem;
border-radius: 9999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
}
.count-num {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
}
.count-label {
text-transform: lowercase;
}
.bot-group-header:first-child {
margin-top: 0;
}
</style>
@@ -114,34 +114,37 @@
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if addingReceiverForTarget === target.id}
<EntitySelect
items={chatItems}
bind:value={receiverForm.chat_id}
open={true}
showTrigger={false}
placeholder={t('telegramBot.selectChat')}
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
onclose={oncancelReceiver}
/>
{/if}
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{:else if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => onloadBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
{#if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
@@ -49,6 +49,7 @@
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
onnameinput?: () => void;
}
let {
@@ -70,6 +71,7 @@
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
onnameinput,
}: Props = $props();
</script>
@@ -87,7 +89,7 @@
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<div class="flex gap-2">
<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>
{#if formType === 'telegram'}
+119 -26
View File
@@ -21,11 +21,15 @@
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { getDescriptor } from '$lib/providers';
import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items);
@@ -71,7 +75,24 @@
let showPreviewFor = $state<Set<string>>(new Set());
let LOCALES = $derived(supportedLocalesCache.items);
let activeLocale = $state<string>('en');
let primaryLocale = $derived(LOCALES[0] || 'en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
* Promote primary to be the active locale once the supported-locales
* cache loads (covers initial mount before openNew/edit ran). Without
* this, opening a form before fetch resolves would stay on '' / 'en'.
*/
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
function toggleSlot(key: string) {
const next = new Set(expandedSlots);
@@ -175,8 +196,16 @@
date_only_format: '%d.%m.%Y',
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
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
let allCapabilities = $derived(capabilitiesCache.items);
let providerTypes = $derived(Object.keys(allCapabilities));
@@ -233,7 +262,25 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
// config in edit mode. Mirrors the same hook on tracking-configs so the
// Notification Tracker form can link directly to the editor instead of
// the generic list. Strips the param afterwards so a browser refresh
// doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allTemplateConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
}
/**
@@ -272,7 +319,8 @@
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
nameManuallyEdited = false;
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) {
@@ -285,7 +333,8 @@
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true; activeLocale = 'en';
nameManuallyEdited = true;
editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100);
@@ -372,12 +421,51 @@
};
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
// Locale coverage — count unique locales present across all slots
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
@@ -399,7 +487,7 @@
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
@@ -420,7 +508,7 @@
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -447,15 +535,19 @@
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
<!-- Locale tabs -->
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
@@ -575,24 +667,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={templateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -26,6 +26,7 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -190,6 +191,14 @@
});
let form: Record<string, any> = $state(defaultForm());
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(() => {
topbarAction.set({
@@ -230,9 +239,42 @@
window.history.replaceState(null, '', cleanUrl);
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function trackingConfigTiles(config: Record<string, any>): MetaTile[] {
const tiles: MetaTile[] = [];
const desc = getDescriptor(config.provider_type);
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
tiles.push({
icon: 'mdiPulse',
value: String(events.length),
label: t('trackingConfig.eventTracking'),
hint: events.map(f => t(f.label)).join(', ') || undefined,
tone: events.length > 0 ? 'lavender' : 'default',
});
if (config.periodic_enabled) {
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
}
if (config.scheduled_enabled) {
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
}
if (config.memory_enabled) {
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
}
if (config.quiet_hours_start && config.quiet_hours_end) {
tiles.push({
icon: 'mdiWeatherNight',
label: `${config.quiet_hours_start}${config.quiet_hours_end}`,
hint: t('trackingConfig.quietHoursStart'),
tone: 'citrus',
mono: true,
});
}
return tiles;
}
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
function edit(c: any) {
form = { ...defaultForm(), ...c };
nameManuallyEdited = true;
editing = c.id; showForm = true;
}
@@ -267,7 +309,7 @@
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
@@ -288,7 +330,7 @@
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
<div class="flex gap-2">
<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)]" />
</div>
</div>
@@ -439,25 +481,26 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
{@const desc = getDescriptor(config.provider_type)}
<Card hover entityId={config.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={trackingConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
+34 -7
View File
@@ -14,6 +14,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
@@ -87,13 +88,38 @@
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
@@ -133,15 +159,16 @@
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
+9
View File
@@ -1,9 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const pkg = JSON.parse(
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
);
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 5175,
proxy: {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.6.0"
version = "0.8.0"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -4,21 +4,24 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from typing import Any, Final
import aiohttp
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
# Discord webhook content limit
MAX_CONTENT_LENGTH = 2000
# Discord API constraints (per webhook docs).
MAX_CONTENT_LENGTH: Final = 2000
MAX_USERNAME_LENGTH: Final = 80
class DiscordClient:
class DiscordClient(HttpProviderClient):
"""Sends messages via Discord webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="discord")
async def send(
self,
@@ -33,6 +36,8 @@ class DiscordClient:
"""
if not 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)
for chunk in chunks:
@@ -42,71 +47,34 @@ class DiscordClient:
if avatar_url:
payload["avatar_url"] = avatar_url
result = await self._post(webhook_url, payload)
if not result["success"]:
result = await self.request("POST", webhook_url, json=payload)
if not result.get("success"):
return result
# Small delay between chunks to respect rate limits
if len(chunks) > 1:
await asyncio.sleep(0.5)
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]:
"""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:
return [text]
chunks = []
chunks: list[str] = []
while text:
if len(text) <= limit:
chunks.append(text)
break
# Try to split at newline
split_at = text.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
piece = text
text = ""
else:
split_at = text.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
piece = text[:split_at]
text = text[split_at:].lstrip("\n")
if piece.strip():
chunks.append(piece)
return chunks or [text]
@@ -7,7 +7,7 @@ import contextlib
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
from typing import Any, AsyncIterator, Awaitable, Callable, Final
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.templates.context import build_template_context
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 (
DiscordReceiver,
EmailReceiver,
MatrixReceiver,
NtfyReceiver,
Receiver,
SlackReceiver,
TelegramReceiver,
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.client import TelegramClient
from .telegram.media import (
@@ -58,7 +41,33 @@ from .webhook.client import WebhookClient
_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
@@ -66,17 +75,23 @@ class TargetConfig:
"""Configuration for a notification target."""
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
locale: str = "en" # default locale for template resolution
config: dict[str, Any]
template_slots: dict[str, dict[str, str]] | None = None
locale: str = "en"
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
provider_api_key: str | None = None
provider_internal_url: str | None = None
provider_external_url: str | None = None
receivers: list[Receiver] = field(default_factory=list)
_SendMethod = Callable[
["NotificationDispatcher", TargetConfig, str, ServiceEvent],
Awaitable[dict[str, Any]],
]
class NotificationDispatcher:
"""Dispatches ServiceEvent notifications to configured targets."""
@@ -90,18 +105,11 @@ class NotificationDispatcher:
self._url_cache = url_cache
self._asset_cache = asset_cache
# Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session
# (saves a TLS handshake per outbound call).
# its connection pool instead of opening a fresh per-dispatch session.
self._shared_session = session
@contextlib.asynccontextmanager
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:
yield self._shared_session
return
@@ -115,11 +123,9 @@ class NotificationDispatcher:
) -> list[dict[str, Any]]:
"""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]}"
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,
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(
*[self._send_to_target(event, t) for t in targets],
*[run_one(t) for t in targets],
return_exceptions=True,
)
results = []
results: list[dict[str, Any]] = []
failures = 0
for target, raw in zip(targets, raw_results):
if isinstance(raw, Exception):
failures += 1
_LOGGER.error(
"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:
if isinstance(raw, dict) and not raw.get("success"):
failures += 1
@@ -155,7 +177,6 @@ class NotificationDispatcher:
def _resolve_template(
self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str:
"""Resolve template string for an event, with locale fallback."""
template_str = DEFAULT_TEMPLATE
if target.template_slots:
locale_map = target.template_slots.get(event.event_type.value)
@@ -166,7 +187,6 @@ class NotificationDispatcher:
def _render_message(
self, event: ServiceEvent, target: TargetConfig, locale: str,
) -> str:
"""Resolve template and render message for a given locale."""
template_str = self._resolve_template(event, target, locale)
ctx = build_template_context(
event, target_type=target.type,
@@ -179,7 +199,6 @@ class NotificationDispatcher:
self, receiver: Receiver, default_message: str,
event: ServiceEvent, target: TargetConfig,
) -> str:
"""Return per-receiver message, re-rendering if receiver has a different locale."""
if receiver.locale and receiver.locale != target.locale:
return self._render_message(event, target, receiver.locale)
return default_message
@@ -187,21 +206,16 @@ class NotificationDispatcher:
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> 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)
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,
"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}"}
# ------------------------------------------------------------------
# Asset preload (Telegram-specific)
# ------------------------------------------------------------------
async def _preload_asset_data(
self,
@@ -210,36 +224,13 @@ class NotificationDispatcher:
session: aiohttp.ClientSession,
max_size: int | None,
) -> None:
"""Download each non-cached asset's bytes once and attach to the entry.
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).
"""
"""Download each non-cached asset's bytes once, with SSRF guard."""
if not assets:
return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
async def fetch(entry: dict[str, Any], media: Any) -> None:
cache, key = self._cache_for_entry(entry)
if cache and key:
cached = cache.get(key)
@@ -251,28 +242,40 @@ class NotificationDispatcher:
url = entry["url"]
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:
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return
data = await resp.read()
except aiohttp.ClientError:
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
return
if max_size is not None and len(data) > max_size:
return
entry["data"] = 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(
self, entry: dict[str, Any],
) -> 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")
if cache_key:
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 None, None
# ------------------------------------------------------------------
# Per-provider handlers
# ------------------------------------------------------------------
async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]:
@@ -296,27 +303,25 @@ class NotificationDispatcher:
max_media = target.config.get("max_media_to_send", 50)
max_group = target.config.get("max_media_per_group", 10)
chunk_delay = target.config.get("media_delay", 500)
max_size = target.config.get("max_asset_size")
if max_size:
max_size = max_size * 1024 * 1024 # MB to bytes
max_size_mb = target.config.get("max_asset_size")
max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb else None
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
if not target.receivers:
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("/")
external_url = (target.provider_external_url or "").rstrip("/")
assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
assets: list[dict[str, Any]] = []
media_assets: list[Any] = []
for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url
if not url:
continue
asset_entry = build_telegram_asset_entry(
url=url or "",
url=url,
media_type=asset.type.value,
api_key=target.provider_api_key,
internal_url=internal_url,
@@ -327,26 +332,15 @@ class NotificationDispatcher:
assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its
# 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)
await self._preload_asset_data(assets, media_assets, session, max_size_bytes)
# 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 = {
asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets
if asset.extra.get("thumbhash")
}
thumbhash_resolver = (
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
thumbhash_resolver = thumbhash_map.get if thumbhash_map else None
client = TelegramClient(
session, bot_token,
@@ -355,39 +349,51 @@ class NotificationDispatcher:
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:
results.append({"success": False, "error": "Invalid telegram receiver"})
continue
return {"success": False, "error": "Invalid telegram receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
text_result = await client.send_message(
chat_id=receiver.chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
)
if not text_result.get("success"):
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error"))
results.append(text_result)
continue
_LOGGER.warning(
"Failed to send to chat %s: %s",
receiver.chat_id, text_result.get("error"),
)
return text_result
if assets:
reply_to = text_result.get("message_id")
media_result = await client.send_notification(
chat_id=receiver.chat_id,
assets=assets,
reply_to_message_id=reply_to,
reply_to_message_id=text_result.get("message_id"),
max_group_size=max_group,
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,
chat_action=chat_action or None,
)
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)
@@ -397,17 +403,10 @@ class NotificationDispatcher:
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
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:
results.append({"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
return {"success": False, "error": "Invalid webhook receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
payload = {
"message": message,
@@ -417,8 +416,10 @@ class NotificationDispatcher:
"collection_id": event.collection_id,
"timestamp": event.timestamp.isoformat(),
}
client = WebhookClient(session, receiver.url, receiver.headers)
results.append(await client.send(payload))
client = WebhookClient(session, receiver.url, safe_headers(receiver.headers))
return await client.send(payload)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -431,7 +432,7 @@ class NotificationDispatcher:
if not smtp_cfg.get("host"):
return {"success": False, "error": "SMTP not configured"}
client = EmailClient(SmtpConfig(
email_client = EmailClient(SmtpConfig(
host=smtp_cfg["host"],
port=int(smtp_cfg.get("port", 587)),
username=smtp_cfg.get("username", ""),
@@ -439,27 +440,28 @@ class NotificationDispatcher:
from_address=smtp_cfg.get("from_address", ""),
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
use_tls=smtp_cfg.get("use_tls", True),
tls_mode=smtp_cfg.get("tls_mode", "auto"),
))
if not target.receivers:
return {"success": False, "error": "No receivers configured"}
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
for receiver in target.receivers:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, EmailReceiver) or not receiver.email:
results.append({"success": False, "error": "Invalid email receiver"})
continue
return {"success": False, "error": "Invalid email receiver"}
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,
subject=subject,
body_text=message,
body_html=message,
body_html=None,
to_name=receiver.name,
)
results.append(result)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
async def _send_discord(
@@ -471,20 +473,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"}
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as 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:
results.append({"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
return {"success": False, "error": "Invalid discord receiver"}
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)
@@ -497,20 +495,16 @@ class NotificationDispatcher:
return {"success": False, "error": "No receivers configured"}
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with self._session_ctx() as 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:
results.append({"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
return {"success": False, "error": "Invalid slack receiver"}
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)
@@ -526,22 +520,23 @@ class NotificationDispatcher:
try:
await avalidate_outbound_url(server_url)
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}"
results: list[dict[str, Any]] = []
async with self._session_ctx() as 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:
results.append({"success": False, "error": "Invalid ntfy receiver"})
continue
return {"success": False, "error": "Invalid ntfy receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send(
return await client.send(
server_url, receiver.topic, message,
title=title, priority=receiver.priority, auth_token=auth_token,
))
)
results = await self._fan_out(target.receivers, send_one)
return self._aggregate_results(results)
@@ -557,33 +552,108 @@ class NotificationDispatcher:
try:
await avalidate_outbound_url(homeserver)
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:
return {"success": False, "error": "No receivers configured"}
results: list[dict[str, Any]] = []
async with self._session_ctx() as session:
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:
results.append({"success": False, "error": "Invalid matrix receiver"})
continue
return {"success": False, "error": "Invalid matrix receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
results.append(await client.send_message(
receiver.room_id, message, html_message=message,
))
# body_html is the same plain text — Matrix accepts the
# 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)
# ------------------------------------------------------------------
# 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
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"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
failures = len(results) - successes
out: dict[str, Any] = {
"success": successes > 0,
"receivers": len(results),
"successes": successes,
"failures": failures,
"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
import html
import logging
import re
import ssl
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from email.headerregistry import Address
from email.message import EmailMessage
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__)
_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
class SmtpConfig:
@@ -22,6 +40,55 @@ class SmtpConfig:
from_address: str = ""
from_name: str = "Notify Bridge"
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:
@@ -30,30 +97,39 @@ class EmailClient:
def __init__(self, smtp_config: SmtpConfig) -> None:
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]:
"""Test SMTP connection and authentication without sending an email."""
try:
import aiosmtplib
except ImportError:
if aiosmtplib is None:
return {"success": False, "error": "aiosmtplib not installed"}
cfg = self._config
if not cfg.host:
return {"success": False, "error": "SMTP host not configured"}
use_tls, start_tls = _resolve_tls(cfg)
try:
smtp = aiosmtplib.SMTP(
hostname=cfg.host,
port=cfg.port,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
use_tls=use_tls,
start_tls=start_tls,
tls_context=self._ssl_context(),
timeout=cfg.timeout_s,
validate_certs=True,
)
await smtp.connect()
if cfg.username and cfg.password:
await smtp.login(cfg.username, cfg.password)
await smtp.quit()
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)
return {"success": False, "error": str(e)}
@@ -65,27 +141,52 @@ class EmailClient:
body_html: str | None = None,
to_name: str = "",
) -> dict[str, Any]:
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
try:
import aiosmtplib
except ImportError:
"""Send an email.
Returns ``{"success": True}`` or ``{"success": False, "error": "..."}``.
``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"}
cfg = self._config
if not cfg.host or not cfg.from_address:
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
# Build email message
msg = MIMEMultipart("alternative")
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
msg["Subject"] = subject
try:
to_addr = _validate_email(to_email)
from_addr = _validate_email(cfg.from_address)
except ValueError as exc:
return {"success": False, "error": f"Invalid email address: {exc}"}
msg.attach(MIMEText(body_text, "plain", "utf-8"))
if body_html:
msg.attach(MIMEText(body_html, "html", "utf-8"))
# EmailMessage with structured Address objects rejects CRLF and
# framework-folds long headers safely. We still strip first because
# 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:
await aiosmtplib.send(
msg,
@@ -93,11 +194,14 @@ class EmailClient:
port=cfg.port,
username=cfg.username or None,
password=cfg.password or None,
use_tls=cfg.use_tls,
start_tls=not cfg.use_tls and cfg.port != 25,
use_tls=use_tls,
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}
except Exception as e:
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
except (SMTPException, OSError) as e:
_LOGGER.error("Failed to send email to %s: %s", to_addr, 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
import asyncio
import logging
import time
from typing import Any
import re
import uuid
from typing import Any, Final
from urllib.parse import quote
import aiohttp
from ..http_base import _MAX_RETRY_AFTER_S, safe_headers
from ..redact import redact, redact_exc
_LOGGER = logging.getLogger(__name__)
# Monotonically increasing transaction counter for idempotent sends
_txn_counter = int(time.time() * 1000)
# Matrix room IDs are ``!opaque:server.name`` per the spec. We also allow
# 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:
global _txn_counter
_txn_counter += 1
return str(_txn_counter)
def _validate_room_id(room_id: str) -> str:
if not room_id:
raise ValueError("room_id is empty")
if not _ROOM_ID_RE.match(room_id):
raise ValueError("room_id format is invalid")
return room_id
class MatrixClient:
@@ -33,49 +47,67 @@ class MatrixClient:
self._homeserver = homeserver_url.rstrip("/")
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(
self,
room_id: str,
message: str,
html_message: str | None = None,
) -> 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:
room_id: Internal room ID (e.g. !abc:matrix.org)
message: Plain text body
html_message: Optional HTML-formatted body
"""
if not room_id:
return {"success": False, "error": "Missing room_id"}
encoded_room = quote(room_id, safe="")
url = (
f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}"
f"/send/m.room.message/{self._txn_id()}"
)
txn_id = _next_txn_id()
# 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,
}
body: dict[str, Any] = {"msgtype": "m.text", "body": message}
if html_message:
body["format"] = "org.matrix.custom.html"
body["formatted_body"] = html_message
headers = {
headers = safe_headers({
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
})
try:
async with self._session.put(
url, json=body, headers=headers, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
resp_body = await resp.text()
if resp.status == 429:
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200])
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with self._session.put(
url, json=body, headers=headers,
timeout=_DEFAULT_TIMEOUT, allow_redirects=False,
) as resp:
if 200 <= resp.status < 300:
return {"success": True}
resp_body = await resp.text()
if resp.status == 429 and attempt < _MAX_RETRIES:
try:
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
import logging
from typing import Any
from typing import Any, Final
import aiohttp
from ..http_base import HttpProviderClient
_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."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="ntfy")
async def send(
self,
@@ -22,41 +37,48 @@ class NtfyClient:
topic: str,
message: str,
title: str | None = None,
priority: int = 3,
priority: int = _DEFAULT_PRIORITY,
tags: list[str] | None = None,
click_url: str | None = None,
auth_token: str | None = None,
markdown: bool = True,
) -> dict[str, Any]:
"""Send a push notification to an ntfy topic."""
if not server_url or not 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] = {
"topic": topic,
"message": message,
"markdown": True,
"markdown": bool(markdown),
}
if title:
payload["title"] = title
if priority != 3:
payload["priority"] = priority
payload["title"] = _strip_crlf(title)
if priority_int != _DEFAULT_PRIORITY:
payload["priority"] = priority_int
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:
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:
headers["Authorization"] = f"Bearer {auth_token}"
try:
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)}
return await self.request("POST", server_url.rstrip("/"), json=payload, headers=headers)
@@ -2,47 +2,88 @@
from __future__ import annotations
import asyncio
import copy
import logging
from datetime import datetime, timezone
from typing import Any
from typing import Any, Final
from notify_bridge_core.storage import StorageBackend
_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:
"""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._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:
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:
if self._data is None:
self._data = {"queue": []}
self._data["queue"].append({
"params": notification_params,
"queued_at": datetime.now(timezone.utc).isoformat(),
})
await self._backend.save(self._data)
async with self._lock:
if self._data is None:
self._data = {"queue": []}
queue: list[dict[str, Any]] = self._data["queue"]
queue.append({
"params": notification_params,
"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]]:
if not self._data:
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:
return bool(self._data and self._data.get("queue"))
async def async_clear(self) -> None:
if self._data:
self._data["queue"] = []
await self._backend.save(self._data)
async with self._lock:
if self._data:
self._data["queue"] = []
await self._backend.save(self._data)
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
async with self._lock:
await self._backend.remove()
self._data = None
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Callable
@dataclass
@@ -70,51 +70,64 @@ class MatrixReceiver(Receiver):
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:
"""Factory: build typed Receiver from target type and config dict."""
if target_type == "telegram":
return TelegramReceiver(
locale=locale,
config=config,
chat_id=str(config.get("chat_id", "")),
)
if target_type == "webhook":
return WebhookReceiver(
locale=locale,
config=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)
"""Factory: build typed Receiver from target type and config dict.
Falls back to a base ``Receiver`` for unknown target types so callers
that handle types defensively still receive a usable object but the
dispatcher rejects them with ``"Unknown target type"`` so a typo can't
silently route to nowhere.
"""
factory = _RECEIVER_FACTORIES.get(target_type)
if factory is None:
return Receiver(locale=locale, config=config)
return factory(locale, 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
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
class SlackClient:
class SlackClient(HttpProviderClient):
"""Sends messages via Slack incoming webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
super().__init__(session, provider_name="slack")
async def send(
self,
@@ -33,19 +35,4 @@ class SlackClient:
if icon_emoji:
payload["icon_emoji"] = icon_emoji
try:
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)}
return await self.request("POST", webhook_url, json=payload)
@@ -1,10 +1,22 @@
"""Outbound URL validation to mitigate SSRF attacks.
User-controlled URLs (provider `url`, webhook target `url`, shared-link
base URLs, image downloads) must be validated before any HTTP request is
issued. This module rejects schemes other than http/https and blocks
destinations that resolve to private, loopback, link-local, or unspecified
address ranges.
User-controlled URLs (provider ``url``, webhook target ``url``,
shared-link base URLs, image downloads) must be validated before any
HTTP request is issued. This module rejects schemes other than
http/https and blocks destinations that resolve to private, loopback,
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
development against localhost services.
@@ -17,12 +29,20 @@ import ipaddress
import logging
import os
import socket
from dataclasses import dataclass
from urllib.parse import urlparse
import aiohttp
_LOGGER = logging.getLogger(__name__)
_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
_LOGGER.warning(
@@ -36,7 +56,29 @@ class UnsafeURLError(ValueError):
"""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:
# 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 (
ip.is_private
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_reserved
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]:
if not isinstance(url, str) or not url:
raise UnsafeURLError("URL is empty")
parsed = urlparse(url)
if parsed.scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
scheme = parsed.scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
raise UnsafeURLError(f"Scheme '{scheme[:16]}' not allowed")
host = parsed.hostname
if not 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:
sockaddr = info[4]
try:
@@ -67,64 +141,143 @@ def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
except ValueError:
continue
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:
"""Validate ``url`` is safe to fetch; returns the URL on success.
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
private addresses are permitted but the scheme check still applies.
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
:func:`avalidate_outbound_url` from async code paths.
.. deprecated::
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
:func:`avalidate_outbound_url` from async code paths so the
event loop isn't blocked, and use :func:`build_ssrf_safe_session`
to defeat DNS rebinding.
"""
_, host = _check_scheme_host(url)
if _ALLOW_PRIVATE:
return url
# Literal IP host
try:
ip = ipaddress.ip_address(host)
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
except ValueError:
pass
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
_check_resolved_addresses(host, infos)
except (socket.gaierror, UnicodeError, OSError) as exc:
# ``UnicodeError`` covers IDNA failures (labels >63 chars, malformed
# 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
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
loop on DNS lookups.
For DNS-rebinding-safe usage, prefer :func:`avalidate_outbound_url_full`
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)
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:
ip = ipaddress.ip_address(host)
if _is_blocked_ip(ip):
raise UnsafeURLError(f"Host {host} is in a blocked range")
return url
ip_obj = ipaddress.ip_address(host)
if _is_blocked_ip(ip_obj):
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
return ValidatedURL(url=url, host=host, ip=str(ip_obj))
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
infos = await loop.getaddrinfo(host, None)
except socket.gaierror as exc:
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
_check_resolved_addresses(host, infos)
return url
except (socket.gaierror, UnicodeError, OSError) as exc:
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
addrs = _select_addresses(host, infos)
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
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any
from typing import Any, Final
from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
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:
@@ -25,7 +38,17 @@ class TelegramFileCache:
Intended for content-addressable assets (e.g. Immich) where re-uploads
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__(
@@ -40,35 +63,40 @@ class TelegramFileCache:
self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
self._lock = asyncio.Lock()
async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired()
async with self._lock:
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:
return
files = self._data["files"]
files: dict[str, dict[str, Any]] = self._data["files"]
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:
now = datetime.now(timezone.utc)
expired = [
url for url, entry in files.items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
expired: list[str] = []
for url, entry in list(files.items()):
cached_at = _parse_iso(entry.get("cached_at"))
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:
del files[key]
changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
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]:
del files[key]
changed = True
@@ -80,7 +108,10 @@ class TelegramFileCache:
if not self._data or "files" not in self._data:
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:
return None
@@ -88,19 +119,23 @@ class TelegramFileCache:
if thumbhash is not None:
stored = entry.get("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
elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at")
if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
cached_at = _parse_iso(entry.get("cached_at"))
if cached_at is not None:
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:
return None
return {
"file_id": entry.get("file_id"),
"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(
@@ -111,21 +146,22 @@ class TelegramFileCache:
thumbhash: str | None = None,
size: int | None = None,
) -> None:
if self._data is None:
self._data = {"files": {}}
async with self._lock:
if self._data is None:
self._data = {"files": {}}
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
self._data["files"][key] = entry
await self._backend.save(self._data)
async def async_set_many(
self,
@@ -139,32 +175,34 @@ class TelegramFileCache:
"""
if not entries:
return
if self._data is None:
self._data = {"files": {}}
async with self._lock:
if self._data is None:
self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat()
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": now_iso,
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
now_iso = datetime.now(timezone.utc).isoformat()
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
"cached_at": now_iso,
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
await self._backend.save(self._data)
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
async with self._lock:
await self._backend.remove()
self._data = None
def stats(self) -> dict[str, Any]:
"""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
(only counts entries with a recorded ``size``), and the oldest /
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 {}
count = len(files)
total_size = 0
oldest: str | None = None
newest: str | None = None
oldest_dt: datetime | None = None
newest_dt: datetime | None = None
oldest_str: str | None = None
newest_str: str | None = None
for entry in files.values():
size = entry.get("size")
if isinstance(size, int):
total_size += size
cached_at = entry.get("cached_at")
if cached_at:
if oldest is None or cached_at < oldest:
oldest = cached_at
if newest is None or cached_at > newest:
newest = cached_at
dt = _parse_iso(cached_at)
if dt is None or not cached_at:
continue
if dt.tzinfo is None:
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 {
"count": count,
"total_size_bytes": total_size,
"oldest": oldest,
"newest": newest,
"oldest": oldest_str,
"newest": newest_str,
}
File diff suppressed because it is too large Load Diff
@@ -2,20 +2,35 @@
from __future__ import annotations
import logging
import re
from typing import Any, Final
from urllib.parse import urlparse
_LOGGER = logging.getLogger(__name__)
# Telegram constants
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
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
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
# Strict canonical-UUID pattern (8-4-4-4-12) for asset IDs. The previous
# 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"
_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)
_ASSET_ID_URL_PATTERNS = [
@@ -162,5 +177,10 @@ def check_photo_limits(
return False, None, width, height
except ImportError:
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
@@ -7,37 +7,29 @@ from typing import Any
import aiohttp
from ..ssrf import UnsafeURLError, avalidate_outbound_url
from ..http_base import HttpProviderClient
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
class WebhookClient(HttpProviderClient):
"""Send JSON payloads to a webhook URL.
class WebhookClient:
"""Send JSON payloads to a webhook URL."""
The URL is SSRF-validated on every send (defense-in-depth: re-validating
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:
self._session = session
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(session, provider_name="webhook")
self._url = url
self._headers = headers or {}
self._extra_headers = headers or {}
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
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)}
return await self.request("POST", self._url, json=payload, headers=self._extra_headers)
@@ -150,6 +150,40 @@ class GiteaClient:
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
return []
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
"""List users known to the Gitea instance via /users/search.
``/users/search`` with an empty ``q`` returns all users the
authenticated token can see, paginated. We cap at ``limit`` to avoid
unbounded memory on large instances; the picker only needs enough to
cover senders that may appear in webhook payloads.
"""
users: list[dict[str, Any]] = []
page = 1
per_page = min(50, limit)
while len(users) < limit:
try:
async with self._session.get(
f"{self._url}/api/v1/users/search",
headers=self._headers,
params={"page": str(page), "limit": str(per_page)},
) as response:
if response.status != 200:
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
break
body = await response.json()
items = body.get("data", []) if isinstance(body, dict) else body
if not items:
break
users.extend(items)
if len(items) < per_page:
break
page += 1
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch users: %s", err)
break
return users[:limit]
class GiteaApiError(Exception):
"""Raised when a Gitea API call fails."""
@@ -0,0 +1,32 @@
"""Upstream release-check providers.
This package is intentionally separate from :mod:`notify_bridge_core.providers`:
* service providers are user-configured entities persisted per-tenant in the DB;
* release providers are admin-level upstream-version probes selected by setting,
with at most one active provider per installation.
Mixing them in one enum/factory bled responsibilities and complicated future
additions (e.g. a GitHub release provider that has nothing to do with Gitea
service integrations).
"""
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProvider,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
)
from .registry import build_release_provider
__all__ = [
"ReleaseErrorCode",
"ReleaseInfo",
"ReleaseProvider",
"ReleaseProviderKind",
"ReleaseTestResult",
"build_release_provider",
"is_valid_repo",
]
@@ -0,0 +1,156 @@
"""ReleaseProvider abstraction and shared tag/version utilities."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Protocol, TypedDict, runtime_checkable
class ReleaseProviderKind(str, Enum):
"""Supported upstream release-check providers."""
DISABLED = "disabled"
GITEA = "gitea"
GITHUB = "github"
# Single source of truth for `release_error` taxonomy. Surfaced into the cached
# `AppSetting`, returned via the API, and translated by the frontend.
class ReleaseErrorCode(str, Enum):
DISABLED = "disabled"
MISCONFIGURED = "misconfigured"
PROVIDER_CHANGED = "provider_changed"
NO_RELEASE_FOUND = "no_release_found"
NETWORK_ERROR = "network_error"
HTTP_ERROR = "http_error"
PARSE_ERROR = "parse_error"
UNSAFE_URL = "unsafe_url"
NOT_IMPLEMENTED = "not_implemented"
UNKNOWN_ERROR = "unknown_error"
@dataclass(frozen=True)
class ReleaseInfo:
"""Normalised release metadata returned by a provider."""
tag: str
version: str
name: str | None = None
body: str | None = None
url: str | None = None
published_at: str | None = None
prerelease: bool = False
draft: bool = False
class ReleaseTestResult(TypedDict):
"""Structured shape returned by :meth:`ReleaseProvider.test`."""
ok: bool
info: ReleaseInfo | None
error: str | None
@runtime_checkable
class ReleaseProvider(Protocol):
"""Protocol implemented by every release provider.
Implementations are expected to be safe to instantiate without external
side effects connectivity is deferred until :meth:`fetch_latest` or
:meth:`test` is awaited.
"""
kind: ClassVar[ReleaseProviderKind]
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
"""Return the latest release, or ``None`` if there is nothing to report."""
async def test(self) -> ReleaseTestResult:
"""Probe the upstream and return a structured status payload."""
# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
def is_valid_repo(repo: str) -> bool:
"""``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""
return bool(repo) and _REPO_RE.match(repo) is not None
_TAG_NUMERIC = re.compile(r"\d+")
# Stop reading numeric segments at the first non-digit-non-dot character so
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
_HEAD_SPLIT = re.compile(r"[^0-9.]")
def normalise_version(tag: str) -> str:
"""Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""
if not tag:
return ""
cleaned = tag.strip()
if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
cleaned = cleaned[1:]
return cleaned
def _split_version(version: str) -> tuple[tuple[int, ...], str]:
"""Split a version into (numeric segments, prerelease suffix).
A non-empty prerelease suffix marks the version as pre-stable. We use it
as a tie-break only when numeric segments are equal a stable build
sorts strictly newer than its pre-release counterpart (``0.7.2`` >
``0.7.2-rc1``), which prevents the badge from flickering between
"up to date" and "downgrade available" on installs that ship the GA.
"""
if not version:
return (), ""
work = version.split("+", 1)[0]
if "-" in work:
head, _, suffix = work.partition("-")
else:
# Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
# first non-digit-non-dot is treated as the suffix.
m = _HEAD_SPLIT.search(work)
if m and m.start() > 0:
head, suffix = work[: m.start()], work[m.start():]
else:
head, suffix = work, ""
segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
return segments, suffix.strip()
def compare_versions(a: str, b: str) -> int:
"""Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.
Numeric segments win. When numerically equal, *stable* (no suffix) beats
*prerelease* (any non-empty suffix); two equally-prereleased versions
compare equal we deliberately do not order ``rc2`` over ``rc1`` because
that requires real semver parsing and would only matter for downgrades.
"""
sa, suffix_a = _split_version(normalise_version(a))
sb, suffix_b = _split_version(normalise_version(b))
length = max(len(sa), len(sb))
for i in range(length):
x = sa[i] if i < len(sa) else 0
y = sb[i] if i < len(sb) else 0
if x != y:
return 1 if x > y else -1
# Equal numerics — stable beats prerelease.
if not suffix_a and suffix_b:
return 1
if suffix_a and not suffix_b:
return -1
return 0
def is_newer(candidate: str, baseline: str) -> bool:
"""``True`` when ``candidate`` is strictly newer than ``baseline``."""
return compare_versions(candidate, baseline) > 0
@@ -0,0 +1,167 @@
"""Gitea release provider — queries ``/api/v1/repos/{owner}/{repo}/releases``."""
from __future__ import annotations
import asyncio
import logging
from typing import ClassVar
import aiohttp
from ..notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
normalise_version,
)
_LOGGER = logging.getLogger(__name__)
# Cap upstream response body — release lists are normally a few KB; anything
# beyond this is either a misconfigured target or a malicious payload.
_MAX_BODY_BYTES = 1_000_000
class GiteaReleaseProvider:
"""Anonymous Gitea release probe.
Hits the ``releases`` endpoint (not ``releases/latest``) because the latter
skips pre-releases unconditionally we want to honour the caller's
``include_prereleases`` flag instead of relying on Gitea's filtering.
"""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITEA
def __init__(self, session: aiohttp.ClientSession, url: str, repo: str) -> None:
if not url:
raise ValueError("Gitea release provider requires a base URL")
if not is_valid_repo(repo):
raise ValueError(
"Gitea release provider requires repo as 'owner/name' "
"(alphanumerics, dot, dash, underscore only)"
)
self._session = session
self._url = url.rstrip("/")
self._repo = repo.strip("/")
@property
def _endpoint(self) -> str:
return f"{self._url}/api/v1/repos/{self._repo}/releases"
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError as err:
_LOGGER.warning("Gitea release URL rejected by SSRF guard: %s", err)
return None
try:
async with self._session.get(
self._endpoint,
params={"limit": "20", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
_LOGGER.warning(
"Gitea releases fetch failed: HTTP %s for %s",
response.status, self._endpoint,
)
return None
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response exceeded %d bytes — refusing to parse",
_MAX_BODY_BYTES,
)
return None
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Gitea releases fetch error: %s", err)
return None
except (ValueError, UnicodeDecodeError) as err:
_LOGGER.warning("Gitea releases parse error: %s", err)
return None
if not isinstance(payload, list):
return None
for entry in payload:
if not isinstance(entry, dict):
continue
if entry.get("draft"):
continue
if entry.get("prerelease") and not include_prereleases:
continue
return _to_release_info(entry)
return None
async def test(self) -> ReleaseTestResult:
# Validate URL first so the "test" button surfaces an SSRF rejection
# to the operator rather than silently returning "unreachable".
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
async with self._session.get(
self._endpoint,
params={"limit": "1", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
return {"ok": False, "info": None, "error": ReleaseErrorCode.HTTP_ERROR.value}
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.NETWORK_ERROR.value}
except (ValueError, UnicodeDecodeError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
if not isinstance(payload, list) or not payload:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NO_RELEASE_FOUND.value}
first = payload[0]
if not isinstance(first, dict):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
return {"ok": True, "info": _to_release_info(first), "error": None}
def _to_release_info(entry: dict) -> ReleaseInfo:
tag = str(entry.get("tag_name") or "").strip()
return ReleaseInfo(
tag=tag,
version=normalise_version(tag),
name=entry.get("name") or None,
body=entry.get("body") or None,
url=entry.get("html_url") or None,
published_at=entry.get("published_at") or entry.get("created_at") or None,
prerelease=bool(entry.get("prerelease", False)),
draft=bool(entry.get("draft", False)),
)
@@ -0,0 +1,34 @@
"""GitHub release provider stub.
Reserved so the registry advertises the option and the frontend can render the
provider toggle without a follow-up backend release. The full implementation
will mirror :class:`GiteaReleaseProvider` against
``api.github.com/repos/{owner}/{repo}/releases``.
"""
from __future__ import annotations
from typing import ClassVar
import aiohttp
from .base import ReleaseErrorCode, ReleaseInfo, ReleaseProviderKind, ReleaseTestResult
class GitHubReleaseProvider:
"""Not yet implemented — placeholder so the registry is forward-compatible."""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITHUB
def __init__(self, session: aiohttp.ClientSession, repo: str) -> None:
self._session = session
self._repo = repo
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
# Soft-fail rather than raise — `run_check` already catches
# NotImplementedError but a None return keeps the persisted
# `release_error` taxonomy clean (NOT_IMPLEMENTED, not "not impl…").
return None
async def test(self) -> ReleaseTestResult:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NOT_IMPLEMENTED.value}
@@ -0,0 +1,51 @@
"""Factory for release providers — single entry point for callers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ReleaseProvider, ReleaseProviderKind, is_valid_repo
from .gitea import GiteaReleaseProvider
from .github import GitHubReleaseProvider
if TYPE_CHECKING:
import aiohttp
def build_release_provider(
kind: str | ReleaseProviderKind,
*,
session: aiohttp.ClientSession,
url: str = "",
repo: str = "",
) -> ReleaseProvider | None:
"""Build a release provider for the given kind.
Returns ``None`` when disabled or when required configuration is missing
or unsafe (invalid repo format, empty URL) callers treat the absence as
"no checks performed" without branching on the kind string everywhere.
"""
try:
normalised = (
ReleaseProviderKind(kind)
if not isinstance(kind, ReleaseProviderKind)
else kind
)
except ValueError:
return None
if normalised is ReleaseProviderKind.DISABLED:
return None
if normalised is ReleaseProviderKind.GITEA:
if not url or not is_valid_repo(repo):
return None
try:
return GiteaReleaseProvider(session=session, url=url, repo=repo)
except ValueError:
return None
if normalised is ReleaseProviderKind.GITHUB:
if not is_valid_repo(repo):
return None
return GitHubReleaseProvider(session=session, repo=repo)
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.6.0"
version = "0.8.0"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -2,13 +2,18 @@
import logging
import os
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from notify_bridge_core.release import ReleaseProviderKind, is_valid_repo
from ..auth.dependencies import get_current_user, require_admin
from ..auth.routes import limiter # shared SlowAPI instance (app.state.limiter)
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -28,6 +33,12 @@ _SETTING_KEYS = {
"log_level": "NOTIFY_BRIDGE_LOG_LEVEL", # DEBUG/INFO/WARNING/ERROR
"log_format": "NOTIFY_BRIDGE_LOG_FORMAT", # text|json (requires restart to switch)
"log_levels": "NOTIFY_BRIDGE_LOG_LEVELS", # module=LEVEL,module2=LEVEL
# Release-check — see services/release_check.py for the cached-state keys.
"release_provider_kind": "NOTIFY_BRIDGE_RELEASE_PROVIDER", # disabled|gitea|github
"release_provider_url": "NOTIFY_BRIDGE_RELEASE_PROVIDER_URL",
"release_provider_repo": "NOTIFY_BRIDGE_RELEASE_PROVIDER_REPO",
"release_include_prereleases": None, # "0"|"1"
"release_check_interval_hours": None, # 1..168
}
_DEFAULTS = {
@@ -42,6 +53,13 @@ _DEFAULTS = {
"log_level": "INFO",
"log_format": "text",
"log_levels": "",
# Pre-seed Gitea release checks against this repo's own upstream so a fresh
# install knows where to look without operator intervention.
"release_provider_kind": "gitea",
"release_provider_url": "https://git.dolgolyov-family.by",
"release_provider_repo": "alexei.dolgolyov/notify-bridge",
"release_include_prereleases": "0",
"release_check_interval_hours": "12",
}
# Settings whose changes require dropping in-memory Telegram caches so the
@@ -53,6 +71,17 @@ _CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_ent
# changing it means swapping the handler formatter entirely.
_LOG_SETTING_KEYS = {"log_level", "log_levels", "log_format"}
# Release-check settings whose change must trigger cache invalidation (so a
# stale "latest version" doesn't linger after pointing at a new repo) and a
# scheduler re-arm so the new interval/provider takes effect immediately.
_RELEASE_PROVIDER_KEYS = {
"release_provider_kind",
"release_provider_url",
"release_provider_repo",
"release_include_prereleases",
}
_RELEASE_INTERVAL_KEY = "release_check_interval_hours"
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
@@ -81,6 +110,11 @@ class SettingsUpdate(BaseModel):
log_level: str | None = None
log_format: str | None = None
log_levels: str | None = None
release_provider_kind: str | None = None
release_provider_url: str | None = None
release_provider_repo: str | None = None
release_include_prereleases: bool | int | str | None = None
release_check_interval_hours: int | str | None = None
@router.get("")
@@ -111,12 +145,65 @@ async def update_settings(
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
old_timezone = await get_setting(session, "timezone")
old_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
old_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
old_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
for key in _SETTING_KEYS:
value = getattr(body, key, None)
if value is None:
continue
value_str = str(value)
# Normalise per-key before storing so the cache keys always hold the
# canonical wire format ("0"/"1" for bool flags, clamped int for the
# release interval). Without this, str(True) would leak "True" into the
# release_include_prereleases cell and silently disable filtering.
if key == "release_include_prereleases":
if isinstance(value, bool):
value_str = "1" if value else "0"
else:
value_str = "1" if str(value).strip().lower() in ("1", "true", "yes", "on") else "0"
elif key == "release_check_interval_hours":
from ..services.release_check import parse_interval_hours
value_str = str(parse_interval_hours(str(value)))
elif key == "release_provider_kind":
# Reject anything outside the enum so a typo doesn't leave the DB
# in a state the service can't interpret.
value_str = str(value).strip().lower()
try:
value_str = ReleaseProviderKind(value_str).value
except ValueError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_kind: {value_str!r}",
) from err
elif key == "release_provider_url":
value_str = str(value).strip()
if value_str:
# Reject embedded userinfo (http://user:pass@host) so the
# GET /settings response can never echo credentials back, and
# block private/loopback/metadata targets via the SSRF guard.
parsed = urlparse(value_str)
if parsed.username or parsed.password:
raise HTTPException(
status_code=400,
detail="release_provider_url must not contain credentials",
)
try:
await avalidate_outbound_url(value_str)
except UnsafeURLError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_url: {err}",
) from err
elif key == "release_provider_repo":
value_str = str(value).strip()
if value_str and not is_valid_repo(value_str):
raise HTTPException(
status_code=400,
detail="release_provider_repo must match 'owner/name' "
"(alphanumerics, dot, dash, underscore only)",
)
else:
value_str = str(value)
# GET masks the webhook secret as "***<last4>" so the real value is
# never exposed to the frontend. If the client sends the mask back
# (which happens on every save, since bind:value holds whatever GET
@@ -182,6 +269,27 @@ async def update_settings(
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
await _reregister_webhooks(session, new_base_url, new_secret)
# Release-check: clear stale cache when the provider repo/url/kind changes,
# and re-arm the periodic job whenever the interval or provider changes.
new_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
new_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
release_provider_changed = new_release_values != old_release_values
release_interval_changed = new_release_interval != old_release_interval
if release_provider_changed:
from datetime import datetime, timezone
from notify_bridge_core.release import ReleaseErrorCode
from ..services.release_check import persist_release_state
await persist_release_state(
checked_at=datetime.now(timezone.utc).isoformat(),
error=ReleaseErrorCode.PROVIDER_CHANGED.value,
info=None,
)
if release_provider_changed or release_interval_changed:
from ..services.scheduler import reschedule_release_check
await reschedule_release_check()
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
@@ -218,6 +326,135 @@ async def get_supported_locales(
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("/")}
def _status_payload(status, *, is_admin: bool) -> dict:
"""Serialise a :class:`ReleaseStatus` for the API.
Non-admin payloads strip the upstream release body (an XSS landmine
arbitrary attacker-controlled markdown should never reach a non-admin
UI unless we explicitly sanitise it for display) and replace the raw
error string with a coarse ``error`` / ``ok`` marker so internal
hostnames from probe failures can't leak via the badge.
"""
payload = {
"provider": status.provider,
"current": status.current,
"latest": status.latest,
"latest_tag": status.latest_tag,
"latest_url": status.latest_url,
"latest_name": status.latest_name,
"latest_published_at": status.latest_published_at,
"latest_prerelease": status.latest_prerelease,
"checked_at": status.checked_at,
"update_available": status.update_available,
}
if is_admin:
payload["latest_body"] = status.latest_body
payload["error"] = status.error
else:
payload["latest_body"] = None
payload["error"] = None if not status.error else "error"
return payload
@router.get("/release")
async def get_release_status(
user: User = Depends(get_current_user),
):
"""Return the cached upstream release status (no network call).
Available to all authenticated users so the sidebar badge can render for
everyone admins manage the configuration but the awareness is global.
"""
from ..services.release_check import load_status
return _status_payload(await load_status(), is_admin=(user.role == "admin"))
@router.post("/release/check")
@limiter.limit("6/minute")
async def force_release_check(
request: Request,
user: User = Depends(require_admin),
):
"""Force an immediate upstream check and return the refreshed status."""
from ..services.release_check import run_check
status = await run_check(force=True)
return _status_payload(status, is_admin=True)
class ReleaseTestRequest(BaseModel):
provider_kind: str
provider_url: str | None = None
provider_repo: str | None = None
include_prereleases: bool | None = False
@router.post("/release/test")
@limiter.limit("12/minute")
async def test_release_provider(
request: Request,
body: ReleaseTestRequest,
user: User = Depends(require_admin),
):
"""Dry-run an arbitrary provider config — used by the cassette's Test button.
Validates the provider URL on the spot (SSRF + userinfo) so the operator
sees an actionable error before any outbound request fires.
"""
from notify_bridge_core.release import ReleaseErrorCode, build_release_provider
from ..services.http_session import get_http_session
test_url = (body.provider_url or "").strip()
test_repo = (body.provider_repo or "").strip()
if test_repo and not is_valid_repo(test_repo):
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
if test_url:
parsed = urlparse(test_url)
if parsed.username or parsed.password:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
await avalidate_outbound_url(test_url)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
http = await get_http_session()
provider = build_release_provider(
body.provider_kind,
session=http,
url=test_url,
repo=test_repo,
)
if provider is None:
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
result = await provider.test()
info = result.get("info")
info_dict = None
if info is not None:
info_dict = {
"tag": info.tag,
"version": info.version,
"name": info.name,
"url": info.url,
"published_at": info.published_at,
"prerelease": info.prerelease,
}
return {"ok": result["ok"], "info": info_dict, "error": result.get("error")}
async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str
) -> None:
@@ -403,7 +403,13 @@ async def get_command_variables(
webhook = {
"status": {
"description": "/status webhook provider summary",
"variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"},
"variables": {
**common_vars,
"trackers_active": "Number of enabled trackers attached to the webhook provider",
"trackers_total": "Total number of trackers attached to the webhook provider",
"provider_name": "Webhook provider name",
"last_event": "Last event timestamp ('YYYY-MM-DD HH:MM' or '-')",
},
},
}
@@ -12,7 +12,7 @@ import aiohttp
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ServiceProvider, User
from ..database.models import EventLog, ServiceProvider, User
from ..services import (
make_immich_provider, make_gitea_provider, make_planka_provider,
make_nut_provider, make_google_photos_provider, list_provider_collections,
@@ -398,6 +398,62 @@ async def list_collections(
return await list_provider_collections(provider)
@router.get("/{provider_id}/users")
async def list_provider_users(
provider_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[dict[str, str]]:
"""Return user identities for sender allowlist/blocklist pickers.
Two sources are merged so the picker is useful both before and after the
first webhook arrives:
- **Provider API** (primary): Gitea's ``/users/search`` returns instance
users the api_token can see. Skipped when no api_token is set.
- **Past senders** (fallback): distinct ``sender`` values from
``EventLog.details`` for this provider, so pre-existing trackers stay
filterable even if the API call fails or is unconfigured.
"""
provider = await _get_user_provider(session, provider_id, user.id)
users_by_id: dict[str, str] = {}
# 1. Try the provider API.
if provider.type == "gitea" and (provider.config or {}).get("api_token"):
from notify_bridge_core.providers.gitea.client import GiteaClient
http_session = await get_http_session()
client = GiteaClient(
http_session,
provider.config.get("url", ""),
provider.config.get("api_token", ""),
)
try:
for u in await client.get_users():
login = u.get("login", "")
if isinstance(login, str) and login:
users_by_id[login] = u.get("full_name") or login
except Exception:
_LOGGER.warning("Failed to fetch Gitea users via API", exc_info=True)
# 2. Merge in past senders (covers users not visible to the API token, or
# cases where the API call fails).
result = await session.exec(
select(EventLog.details).where(EventLog.provider_id == provider.id)
)
for details in result.all():
if not isinstance(details, dict):
continue
sender = details.get("sender", "")
if isinstance(sender, str) and sender and sender not in users_by_id:
users_by_id[sender] = sender
return [
{"id": login, "name": name}
for login, name in sorted(users_by_id.items(), key=lambda kv: kv[0].lower())
]
@router.get("/{provider_id}/albums/{album_id}/shared-links")
async def get_album_shared_links(
provider_id: int,
@@ -118,6 +118,31 @@ async def get_status(
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
# Live-resolve command tracker and bot names for command_* events
# (mirrors the action/tracker pattern above). Falls back to the
# snapshot stored on the EventLog when the entity has been deleted.
cmd_tracker_ids = {
e.command_tracker_id for e in event_rows if e.command_tracker_id is not None
}
cmd_tracker_name_map: dict[int, str] = {}
if cmd_tracker_ids:
cmd_tracker_rows = (await session.exec(
select(CommandTracker.id, CommandTracker.name).where(
CommandTracker.id.in_(cmd_tracker_ids)
)
)).all()
cmd_tracker_name_map = {tid: tname for tid, tname in cmd_tracker_rows}
bot_ids = {
e.telegram_bot_id for e in event_rows if e.telegram_bot_id is not None
}
bot_name_map: dict[int, str] = {}
if bot_ids:
bot_rows = (await session.exec(
select(TelegramBot.id, TelegramBot.name).where(TelegramBot.id.in_(bot_ids))
)).all()
bot_name_map = {bid: bname for bid, bname in bot_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
@@ -135,11 +160,30 @@ async def get_status(
return f"(deleted) {e.action_name}"
return ""
def _display_command_tracker_name(e: EventLog) -> str:
if (
e.command_tracker_id is not None
and e.command_tracker_id in cmd_tracker_name_map
):
return cmd_tracker_name_map[e.command_tracker_id]
if e.command_tracker_name:
return f"(deleted) {e.command_tracker_name}"
return ""
def _display_bot_name(e: EventLog) -> str:
if e.telegram_bot_id is not None and e.telegram_bot_id in bot_name_map:
return bot_name_map[e.telegram_bot_id]
if e.bot_name:
return f"(deleted) {e.bot_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
For command events the ``collection_name`` already stores the
rendered ``/cmd args`` string so we just pass it through.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
@@ -155,9 +199,14 @@ async def get_status(
"id": e.id,
"event_type": e.event_type,
"collection_name": _display_subject(e),
"tracker_id": e.tracker_id,
"tracker_name": _display_tracker_name(e),
"action_id": e.action_id,
"action_name": _display_action_name(e),
"command_tracker_id": e.command_tracker_id,
"command_tracker_name": _display_command_tracker_name(e),
"telegram_bot_id": e.telegram_bot_id,
"bot_name": _display_bot_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
@@ -28,8 +28,9 @@ from ..database.models import (
WebhookPayloadLog,
)
from ..services.dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -164,7 +165,16 @@ async def _dispatch_webhook_event(
Number of successfully dispatched notifications.
"""
dispatched = 0
# ``defers_to_schedule`` is collected during the loop and flushed AFTER the
# main session commits — the only side-effect of failing to schedule is a
# delayed delivery (the startup loader / catch-up scan will reschedule),
# so this is best-effort and must not roll back the DB writes.
defers_to_schedule: set[Any] = set()
async with AsyncSession(engine) as session:
# App timezone is identical across trackers within one webhook request;
# pull it once.
app_tz = await get_app_timezone(session)
tracker_result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider_id,
@@ -173,6 +183,8 @@ async def _dispatch_webhook_event(
)
trackers = tracker_result.all()
from ..services.deferred_dispatch import defer_event, is_deferrable
for tracker in trackers:
filters = tracker.filters or {}
if not _passes_filters(event, filters):
@@ -185,11 +197,9 @@ async def _dispatch_webhook_event(
if not link_data:
continue
app_tz = await get_app_timezone(session)
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
event_log_row = EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id,
tracker_name=tracker.name,
@@ -203,18 +213,90 @@ async def _dispatch_webhook_event(
"provider_type": event.provider_type.value,
**extra_details,
},
))
)
session.add(event_log_row)
await session.flush()
event_log_id = event_log_row.id
# Dispatch to targets
# Dedupe defers by parent ``link_id``: broadcast links emit one
# ``link_data`` entry per child, all sharing the same parent id —
# the deferred row is one-per-link, so we only call ``defer_event``
# once per distinct id (earliest fire_at wins on ties).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
defers_for_event: dict[int, Any] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp event_log dispatch_status in the same
# session that holds the EventLog row, so the "deferred" badge
# only appears if the underlying queue rows actually exist.
if defers_for_event:
earliest = min(defers_for_event.values())
for link_id, fire_at in defers_for_event.items():
await defer_event(
session,
event=event,
user_id=tracker.user_id,
tracker_id=tracker.id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
details = dict(event_log_row.details or {})
if not details.get("dispatch_status"):
details["dispatch_status"] = "deferred"
details["deferred_until"] = earliest.isoformat()
event_log_row.details = details
session.add(event_log_row)
defers_to_schedule.update(defers_for_event.values())
# Dispatch to targets. Isolate dispatcher exceptions per group so
# a failed remote call doesn't bubble out, abort the surrounding
# transaction, and roll back the just-written defers/event_log.
from ..services.http_session import get_http_session
dispatcher = NotificationDispatcher(session=await get_http_session())
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
for tc, target_configs in groups.values():
if not target_configs:
continue
shaped_event = apply_tracking_display_filters(event, tc)
if shaped_event is None:
continue
results = await dispatcher.dispatch(shaped_event, target_configs)
try:
results = await dispatcher.dispatch(shaped_event, target_configs)
except Exception as err: # noqa: BLE001
_LOGGER.exception(
"Dispatcher raised for tracker %d: %s", tracker.id, err,
)
continue
for r in results:
if r.get("success"):
dispatched += 1
@@ -226,6 +308,18 @@ async def _dispatch_webhook_event(
await session.commit()
# Schedule drain jobs OUTSIDE the DB session so an APScheduler hiccup
# can't roll back the persisted defer rows.
if defers_to_schedule:
from ..services.scheduler import schedule_deferred_drain
for fire_at in defers_to_schedule:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
return dispatched
@@ -554,41 +648,3 @@ async def generic_webhook(token: str, request: Request):
await log_session.commit()
return {"ok": True, "dispatched": dispatched}
def _build_target_groups(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[tuple[Any, list[TargetConfig]]]:
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
Targets sharing a TrackingConfig dispatch together so a single
``apply_tracking_display_filters`` pass can shape one event for the
whole group; targets with different TCs may see differently-shaped
events (e.g. one with favorites_only, one without).
"""
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
return list(groups.values())
@@ -33,11 +33,13 @@ def _auto_register() -> None:
from .gitea_handler import GiteaCommandHandler
from .planka_handler import PlankaCommandHandler
from .nut_handler import NutCommandHandler
from .webhook_handler import WebhookCommandHandler
register_handler(ImmichCommandHandler())
register_handler(GiteaCommandHandler())
register_handler(PlankaCommandHandler())
register_handler(NutCommandHandler())
register_handler(WebhookCommandHandler())
# Auto-register on import
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
return sorted(enabled), merged_limits
# ---------------------------------------------------------------------------
# Event logging
# ---------------------------------------------------------------------------
def _format_command_subject(cmd: str, args: str) -> str:
"""Render the dashboard ``collection_name`` for a command event."""
args = (args or "").strip()
return f"/{cmd} {args}".rstrip() if args else f"/{cmd}"
def _normalize_issuer(issuer: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip a Telegram ``from`` payload to the fields the dashboard needs.
Telegram's ``from`` carries plenty we don't want to persist (premium
badge, language code already captured elsewhere, etc.). Keep just
the identity bits and drop anything else so future Telegram changes
can't accidentally start logging extra PII.
"""
if not issuer:
return None
keep = ("id", "username", "first_name", "last_name", "is_bot")
out = {k: issuer[k] for k in keep if k in issuer and issuer[k] not in (None, "")}
return out or None
async def _log_command_event(
*,
bot: TelegramBot,
chat_id: str,
cmd: str,
args: str,
locale: str,
event_type: str,
responses: list[CommandResponse],
ctx_tuples: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
],
extra_details: dict[str, Any] | None = None,
issuer: dict[str, Any] | None = None,
) -> None:
"""Persist a single ``EventLog`` row for a bot-command invocation.
One row per user invocation. Per-tracker breakdown lives in ``details``
(``tracker_count`` / ``responses_count``). Best-effort: a logging
failure must never block the user-visible reply, so we swallow.
"""
try:
first_tracker: CommandTracker | None = None
first_provider: ServiceProvider | None = None
if ctx_tuples:
first_tracker, _, first_provider, _ = ctx_tuples[0]
media_total = sum(len(r.media or []) for r in responses)
details: dict[str, Any] = {
"command": cmd,
"args": args or "",
"chat_id": chat_id,
"locale": locale,
"tracker_count": len(ctx_tuples),
"responses_count": len(responses),
}
normalized_issuer = _normalize_issuer(issuer)
if normalized_issuer:
details["issuer"] = normalized_issuer
if extra_details:
details.update(extra_details)
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=bot.user_id,
tracker_id=None,
tracker_name="",
action_id=None,
action_name="",
command_tracker_id=first_tracker.id if first_tracker else None,
command_tracker_name=first_tracker.name if first_tracker else "",
telegram_bot_id=bot.id,
bot_name=bot.name or "",
provider_id=first_provider.id if first_provider else None,
provider_name=(first_provider.name if first_provider else "") or "",
event_type=event_type,
collection_id=str(chat_id),
collection_name=_format_command_subject(cmd, args),
assets_count=media_total,
details=details,
))
await session.commit()
except Exception: # noqa: BLE001 — diagnostic only, never block reply
_LOGGER.exception(
"Failed to log command event bot=%d chat=%s cmd=/%s",
bot.id, chat_id, cmd,
)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
@@ -271,12 +366,18 @@ async def handle_command(
chat_id: str,
text: str,
language_code: str = "",
*,
issuer: dict[str, Any] | None = None,
) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers.
Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config.
``issuer`` is the Telegram ``from`` object (``{id, username,
first_name, last_name, language_code}``) when known. Stored on the
EventLog row so the dashboard can show *who* invoked the command.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
@@ -292,10 +393,20 @@ async def handle_command(
# Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id)
# Universal commands have no tracker/provider context.
if cmd == "start":
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=[], issuer=issuer,
)
return responses
# Unknown / disabled command — caller treats this the same as "no
# match" and we deliberately do NOT log it (avoids dashboard spam
# from random ``/foo`` traffic).
if cmd not in enabled and cmd != "start":
return None
@@ -307,13 +418,26 @@ async def handle_command(
cmd, bot.id, chat_id, wait,
)
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_rate_limited", responses=responses,
ctx_tuples=ctx_tuples, extra_details={"wait_seconds": wait},
issuer=issuer,
)
return responses
# Universal commands — single merged response
if cmd == "help":
ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=ctx_tuples, issuer=issuer,
)
return responses
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
@@ -329,48 +453,69 @@ async def handle_command(
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
dispatched_ctx: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
] = []
try:
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
if result is not None:
responses.append(result)
dispatched_ctx.append((tracker, config, provider, listener))
except Exception as exc: # noqa: BLE001 — log then re-raise
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_failed", responses=responses,
ctx_tuples=ctx_tuples,
extra_details={"error": f"{type(exc).__name__}: {exc}"},
issuer=issuer,
)
if result is not None:
responses.append(result)
raise
return responses if responses else None
if responses:
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=dispatched_ctx, issuer=issuer,
)
return responses
return None
def _cmd_help(
@@ -120,7 +120,11 @@ async def telegram_webhook(
async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text),
):
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
responses = await handle_command(
bot, chat_id, text,
language_code=effective_lang,
issuer=from_user or None,
)
if not responses:
_LOGGER.info(
"Command produced no response (cmd=%r) after %.0f ms",
@@ -0,0 +1,89 @@
"""Generic webhook provider bot command handler.
The generic webhook provider has no upstream API to query its only
runtime signal is the stream of incoming webhook payloads recorded as
``EventLog`` rows. ``/status`` therefore reports DB-derived stats:
* ``trackers_active`` enabled ``NotificationTracker`` rows for the provider
* ``trackers_total`` all ``NotificationTracker`` rows for the provider
* ``provider_name`` the provider's display name
* ``last_event`` formatted timestamp of the most recent received event,
or ``-`` if nothing has been received yet
"""
from __future__ import annotations
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
NotificationTracker,
ServiceProvider,
TelegramBot,
)
from .base import CommandResponse, ProviderCommandHandler
from .command_utils import get_last_event_str
from .handler import _render_cmd_template
_WEBHOOK_COMMANDS = {"status"}
async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
"""Build the context for ``/status`` on a webhook provider."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider.id
)
)
trackers = list(result.all())
active = [t for t in trackers if t.enabled]
last_str = await get_last_event_str([t.id for t in active])
return {
"trackers_active": len(active),
"trackers_total": len(trackers),
"provider_name": provider.name or "",
"last_event": last_str,
}
class WebhookCommandHandler(ProviderCommandHandler):
"""Handles ``/status`` for generic-webhook providers."""
provider_type = "webhook"
def get_provider_commands(self) -> set[str]:
return _WEBHOOK_COMMANDS
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — webhook has no album scope
page: int = 1,
) -> CommandResponse | None:
if cmd != "status":
return None
ctx = await _cmd_status(provider)
return CommandResponse(
text=_render_cmd_template(cmd_templates, "status", locale, ctx),
)
@@ -90,6 +90,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
@@ -105,6 +109,8 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
("ix_event_log_command_tracker_id", "command_tracker_id"),
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
@@ -197,6 +203,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added filters column to %s table", tracker_table)
# Drop legacy batch_duration column from notification_tracker.
# The field was removed from the SQLModel class but the column still
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
if await _has_table(conn, tracker_table):
if await _has_column(conn, tracker_table, "batch_duration"):
_assert_ident(tracker_table, "table")
await conn.execute(
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
)
logger.info(
"Dropped legacy batch_duration column from %s table",
tracker_table,
)
# Add Gitea tracking flags to tracking_config if missing
if await _has_table(conn, "tracking_config"):
gitea_flags = [
@@ -1348,6 +1369,12 @@ _INDEXES: list[tuple[str, str, str]] = [
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
("ix_action_rule_action_id", "action_rule", "action_id"),
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
# ORDER BY fire_at. The composite (status, fire_at) is the only access
# pattern; an individual fire_at index isn't needed.
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
]
@@ -1376,6 +1403,129 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
Early builds of this feature created the table with a default ``NO ACTION``
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
retention horizon with SQLite's enforced foreign_keys PRAGMA, a pending
DeferredDispatch row pointing at an aging-out event_log row would block
the cleanup with an FK violation.
SQLite can't ALTER a constraint without rebuilding the table. The table
has zero rows in any prod install old enough to need this fix (the
feature shipped in the same release as this migration), so a drop +
recreate via ``create_all`` is safe.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type='table' AND name='deferred_dispatch'"
)
).fetchone()
)
ddl = (row[0] or "") if row else ""
if "ON DELETE SET NULL" in ddl.upper():
return
# Confirm there's nothing to migrate — refuse to drop a populated
# table even though the schema was wrong. Better to leave a warning
# than to lose state.
count_row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text("SELECT COUNT(*) FROM deferred_dispatch")
).fetchone()
)
if count_row and count_row[0]:
logger.warning(
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
"table holds %d rows; not auto-dropping. Inspect manually.",
count_row[0],
)
return
await conn.execute(text("DROP TABLE deferred_dispatch"))
logger.info(
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
"with ON DELETE SET NULL on event_log_id",
)
# Recreate the table from the SQLModel metadata in this same txn.
from sqlmodel import SQLModel
# Ensure the model is registered on metadata before we ask create_all
# to build it. Lazy import to avoid a circular at module load time.
from .models import DeferredDispatch # noqa: F401
await conn.run_sync(
SQLModel.metadata.create_all,
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
)
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
"""Add a partial unique index preventing duplicate pending defers.
Without this, two webhook handlers (or a webhook racing the watcher)
can both call ``_find_pending_asset_rows`` and find nothing, then both
INSERT defeating coalescing. The partial index makes the second
INSERT raise ``IntegrityError`` and the caller's transaction abort,
after which a retry will see the now-visible row.
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
Once the table exists this is safe to run on every boot.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
try:
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"ux_deferred_dispatch_pending "
"ON deferred_dispatch(link_id, collection_id, event_type) "
"WHERE status = 'pending'"
))
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create partial unique index on deferred_dispatch",
exc_info=True,
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
Earlier versions of the frontend stored ``chat_action`` inside
``notification_target.config``; the dedicated ``chat_action`` column
was rarely set or held a stale default. The dispatcher's resolver
overrode the config value with the (stale) column, so a user's UI
choice silently had no effect on outgoing chat actions.
This backfill takes the config value as authoritative (it's what the
UI was writing) and copies it to the column, then strips it from
config so the column becomes the single source of truth. Idempotent:
a second run finds nothing to migrate.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_column(conn, "notification_target", "chat_action"):
return
# Copy config["chat_action"] → column where present.
await conn.execute(text(
"UPDATE notification_target "
"SET chat_action = json_extract(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
# Strip the legacy key so the column is unambiguous going forward.
await conn.execute(text(
"UPDATE notification_target "
"SET config = json_remove(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
logger.info("Migrated chat_action from config JSON to column where present")
# ---------------------------------------------------------------------------
# Schema version tracking — lightweight alternative to Alembic while the
# hand-rolled idempotent migrations remain the source of truth. Gives
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint, Text
from sqlalchemy import ForeignKey, UniqueConstraint, Text
from sqlmodel import JSON, Column, Field, SQLModel
@@ -494,6 +494,64 @@ class CommandTrackerListener(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class DeferredDispatch(SQLModel, table=True):
"""A dispatch held back by quiet hours, waiting for the window to end.
One row per ``(link, event_type, collection_id)`` for asset events newly
arriving events for the same key coalesce into the existing row's
``event_payload`` (union of added/removed asset sets) instead of inserting
a duplicate row. Non-asset events (push, pr_opened, ups_*, ) get a fresh
row each time because they aren't logically cancellable.
At drain time the scheduler picks up rows where ``status='pending'`` and
``fire_at <= now``, re-resolves the link/target/config against current
state (so subsequent config edits apply), and dispatches.
"""
__tablename__ = "deferred_dispatch"
id: int | None = Field(default=None, primary_key=True)
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
tracker_id: int = Field(foreign_key="notification_tracker.id", index=True)
# The specific link this deferral targets. On drain we re-fetch by ID; if
# the link was disabled or removed in the meantime we drop with a
# ``deferred_then_dropped`` log row instead of dispatching to nothing.
link_id: int = Field(
foreign_key="notification_tracker_target.id", index=True,
)
# The event_log row written when the event was first detected. The drain
# writes a follow-up event_log row referencing this id so the dashboard
# can show "delivered at HH:MM, originally detected at HH:MM".
#
# ``ondelete="SET NULL"`` matters because the daily ``_cleanup_old_events``
# job hard-deletes event_log rows past the retention horizon. Without
# SET NULL, an old pending DeferredDispatch row referencing an aging-out
# event_log row would either (a) prevent the delete with an FK violation
# under SQLite's enforced foreign_keys PRAGMA, or (b) leave a dangling
# reference on engines that don't enforce.
event_log_id: int | None = Field(
default=None,
sa_column=Column(
"event_log_id",
ForeignKey("event_log.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
event_type: str = Field(index=True)
collection_id: str = Field(default="", index=True)
# ``dataclasses.asdict(ServiceEvent)`` with datetime/enum normalisation —
# round-tripped via the helpers in ``services.deferred_dispatch``.
event_payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
fire_at: datetime = Field(index=True)
# ``pending`` until the drain runs; then ``fired``, ``dropped`` (link
# gone / event-type disabled after defer), or ``cancelled`` (coalesced
# away by a counter-event).
status: str = Field(default="pending", index=True)
fired_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
class EventLog(SQLModel, table=True):
"""Log of detected events."""
@@ -519,6 +577,17 @@ class EventLog(SQLModel, table=True):
default=None, foreign_key="action.id", index=True,
)
action_name: str = Field(default="")
# Bot command provenance. Populated when ``event_type`` starts with
# ``command_`` so the dashboard can render command activity alongside
# tracker and action events. NULL for non-command rows.
command_tracker_id: int | None = Field(
default=None, foreign_key="command_tracker.id", index=True,
)
command_tracker_name: str = Field(default="")
telegram_bot_id: int | None = Field(
default=None, foreign_key="telegram_bot.id", index=True,
)
bot_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="")
event_type: str = Field(index=True)
@@ -75,6 +75,9 @@ async def lifespan(app: FastAPI):
migrate_notification_slot_locale,
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_deferred_dispatch_event_log_fk,
migrate_deferred_dispatch_unique_pending,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -98,6 +101,12 @@ async def lifespan(app: FastAPI):
await migrate_notification_slot_locale(engine)
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
# FK-rebuild MUST run before the unique-index creation: drop+create_all
# of deferred_dispatch wipes its indexes; the next migration re-establishes
# the partial unique index.
await migrate_deferred_dispatch_event_log_fk(engine)
await migrate_deferred_dispatch_unique_pending(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -145,11 +154,8 @@ async def lifespan(app: FastAPI):
await dispose_engine()
try:
from importlib.metadata import version as _pkg_version
_APP_VERSION = _pkg_version("notify-bridge-server")
except Exception: # pragma: no cover — editable install edge cases
_APP_VERSION = "0.0.0+unknown"
from .version import resolve_version as _resolve_version
_APP_VERSION = _resolve_version()
app = FastAPI(title="Notify Bridge", version=_APP_VERSION, lifespan=lifespan)
@@ -0,0 +1,798 @@
"""Deferred-dispatch infrastructure for quiet-hours notifications.
When ``evaluate_event_gate`` returns ``QUIET_HOURS`` for a deferrable event
type, the dispatch site calls :func:`defer_event` instead of dropping. That
either inserts a new ``DeferredDispatch`` row or coalesces the event into an
existing pending row for the same ``(link_id, collection_id)`` asset add
+ matching remove cancels out, asset add + asset add merges set-union.
An APScheduler one-shot ``date`` job per quiet-window-end fires
:func:`drain_deferred_due` which:
1. Re-resolves each pending row's link/target/configs against current state.
2. Drops rows whose link/target was deleted or disabled in the meantime.
3. Re-checks quiet hours (in case the user extended the window mid-flight)
and pushes ``fire_at`` to the new end if still suppressed.
4. Dispatches via the existing ``NotificationDispatcher``.
5. Writes a follow-up ``event_log`` row referencing the original
``event_log_id`` so the dashboard shows "delivered late".
Wall-clock event types (``scheduled_message``) are explicitly NOT in
``_DEFERRABLE_EVENT_TYPES`` delivering a "good morning" memory at 3 pm is
worse than dropping it. Those keep the legacy drop-on-quiet-hours behavior.
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
from datetime import datetime, timezone
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.notifications.dispatcher import (
NotificationDispatcher,
TargetConfig,
)
from notify_bridge_core.providers.base import ServiceProviderType
from ..database.engine import get_engine
from ..database.models import (
DeferredDispatch,
EventLog,
NotificationTracker,
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Policy
# ---------------------------------------------------------------------------
# Change-driven event types that are safe to deliver after the quiet window
# ends — the underlying state change (a photo was added, a PR was opened, the
# UPS went on battery) remains relevant even hours later. Wall-clock event
# types (``scheduled_message``) are deliberately excluded: a "good morning"
# delivered at 3 pm is wrong, drop is more correct than late delivery.
_DEFERRABLE_EVENT_TYPES: frozenset[str] = frozenset({
# Immich
"assets_added", "assets_removed",
"collection_renamed", "collection_deleted", "sharing_changed",
# Gitea
"push",
"issue_opened", "issue_closed", "issue_commented",
"pr_opened", "pr_closed", "pr_merged", "pr_commented",
"release_published",
# Planka
"card_created", "card_updated", "card_moved", "card_deleted",
"card_commented", "comment_updated",
"board_created", "board_updated", "board_deleted",
"list_created", "list_updated", "list_deleted",
"attachment_created", "card_label_added", "task_completed",
# Generic webhook
"webhook_received",
# NUT (UPS)
"ups_online", "ups_on_battery", "ups_low_battery",
"ups_battery_restored", "ups_comms_lost", "ups_comms_restored",
"ups_replace_battery", "ups_overload",
})
# Per-tracker cap on the pending queue. A misconfigured short quiet window
# plus a chatty upstream (e.g. mass-imported album) could otherwise grow
# unbounded. On overflow we drop oldest (FIFO) — recent events still survive
# to be delivered, ancient ones are sacrificed.
_MAX_PENDING_PER_TRACKER = 1000
# Per-row timeout in the drain. Without this, a single hanging Telegram/SMTP
# call could stall the whole drain for hours and leave the rest of the queue
# stranded. Generous because legitimate large media uploads can take minutes.
_DRAIN_DISPATCH_TIMEOUT_SECONDS = 120
def is_deferrable(event_type: str) -> bool:
"""Whether this event type should be deferred (vs. dropped) during quiet hours."""
return event_type in _DEFERRABLE_EVENT_TYPES
# ---------------------------------------------------------------------------
# ServiceEvent (de)serialization
# ---------------------------------------------------------------------------
#
# JSON column stores ``dataclasses.asdict(event)`` plus a normalisation pass
# for datetimes (ISO strings) and enums (string values). Round-trip via the
# reverse pass below.
def _normalize_for_json(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (EventType, MediaType, ServiceProviderType)):
return value.value
if isinstance(value, dict):
return {k: _normalize_for_json(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_for_json(v) for v in value]
return value
def serialize_event(event: ServiceEvent) -> dict[str, Any]:
"""Convert a ``ServiceEvent`` to a JSON-safe dict for ``DeferredDispatch.event_payload``."""
return _normalize_for_json(dataclasses.asdict(event))
def _parse_dt(s: Any) -> datetime:
if isinstance(s, datetime):
return s
return datetime.fromisoformat(s)
def _deserialize_asset(data: dict[str, Any]) -> MediaAsset:
return MediaAsset(
id=data["id"],
type=MediaType(data["type"]),
filename=data["filename"],
created_at=_parse_dt(data["created_at"]),
owner_name=data.get("owner_name"),
description=data.get("description"),
tags=list(data.get("tags") or []),
thumbnail_url=data.get("thumbnail_url"),
preview_url=data.get("preview_url"),
full_url=data.get("full_url"),
extra=dict(data.get("extra") or {}),
)
def deserialize_event(data: dict[str, Any]) -> ServiceEvent:
"""Inverse of :func:`serialize_event`."""
return ServiceEvent(
event_type=EventType(data["event_type"]),
provider_type=ServiceProviderType(data["provider_type"]),
provider_name=data["provider_name"],
collection_id=data["collection_id"],
collection_name=data["collection_name"],
timestamp=_parse_dt(data["timestamp"]),
added_assets=[_deserialize_asset(a) for a in data.get("added_assets") or []],
removed_asset_ids=list(data.get("removed_asset_ids") or []),
added_count=int(data.get("added_count") or 0),
removed_count=int(data.get("removed_count") or 0),
old_name=data.get("old_name"),
new_name=data.get("new_name"),
old_shared=data.get("old_shared"),
new_shared=data.get("new_shared"),
extra=dict(data.get("extra") or {}),
)
# ---------------------------------------------------------------------------
# Coalescing
# ---------------------------------------------------------------------------
def _added_ids(payload: dict[str, Any]) -> list[str]:
return [a["id"] for a in payload.get("added_assets") or [] if "id" in a]
def _coalesce_assets_added(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Apply add-then-remove cancellation and add-then-add union.
Returns ``(action, updated_added_row, updated_removed_row)`` where action
is one of ``"insert"`` (caller must create a new row), ``"merge"`` (update
existing rows in place caller must session.add them).
"""
new_ids = [a.id for a in new_event.added_assets]
new_ids_set = set(new_ids)
# 1) If a matching assets_removed row pending: subtract — that's a re-add.
if existing_removed_row is not None:
removed_ids = list(existing_removed_row.event_payload.get("removed_asset_ids") or [])
kept = [rid for rid in removed_ids if rid not in new_ids_set]
if len(kept) != len(removed_ids):
payload = dict(existing_removed_row.event_payload)
payload["removed_asset_ids"] = kept
payload["removed_count"] = len(kept)
existing_removed_row.event_payload = payload
if not kept:
# All previously-removed IDs are being re-added → entire
# removal is cancelled. Mark for caller to delete.
existing_removed_row.status = "cancelled"
# The intersection re-adds are accounted for by the cancellation;
# remaining new IDs (those NOT in removed list) still need to land
# in the assets_added row.
new_ids = [nid for nid in new_ids if nid not in set(removed_ids)]
new_ids_set = set(new_ids)
if not new_ids:
# All new added IDs cancelled an existing remove → nothing to enqueue.
return ("merge", None, existing_removed_row)
if existing_added_row is None:
return ("insert", None, existing_removed_row)
# 2) Union with existing assets_added — earliest fire_at wins.
payload = dict(existing_added_row.event_payload)
existing_assets = list(payload.get("added_assets") or [])
seen = {a.get("id") for a in existing_assets}
new_serialized = serialize_event(new_event)
for a in new_serialized.get("added_assets") or []:
if a.get("id") in new_ids_set and a.get("id") not in seen:
existing_assets.append(a)
seen.add(a.get("id"))
payload["added_assets"] = existing_assets
payload["added_count"] = len(existing_assets)
existing_added_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
def _coalesce_assets_removed(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Mirror of :func:`_coalesce_assets_added` for removal events."""
new_ids = list(new_event.removed_asset_ids)
new_ids_set = set(new_ids)
# 1) If a matching assets_added row pending: subtract — that's an
# add-then-remove within the window, cancel both sides.
if existing_added_row is not None:
added = list(existing_added_row.event_payload.get("added_assets") or [])
kept_assets = [a for a in added if a.get("id") not in new_ids_set]
if len(kept_assets) != len(added):
payload = dict(existing_added_row.event_payload)
payload["added_assets"] = kept_assets
payload["added_count"] = len(kept_assets)
existing_added_row.event_payload = payload
if not kept_assets:
existing_added_row.status = "cancelled"
# IDs that were just added during the window don't need to flow
# into the assets_removed row — they're a wash.
cancelled_ids = {a.get("id") for a in added if a.get("id") in new_ids_set}
new_ids = [nid for nid in new_ids if nid not in cancelled_ids]
new_ids_set = set(new_ids)
if not new_ids:
return ("merge", existing_added_row, None)
if existing_removed_row is None:
return ("insert", existing_added_row, None)
# 2) Union with existing assets_removed — earliest fire_at wins.
payload = dict(existing_removed_row.event_payload)
existing_ids = list(payload.get("removed_asset_ids") or [])
seen = set(existing_ids)
for rid in new_ids:
if rid not in seen:
existing_ids.append(rid)
seen.add(rid)
payload["removed_asset_ids"] = existing_ids
payload["removed_count"] = len(existing_ids)
existing_removed_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
async def _find_pending_asset_rows(
session: AsyncSession,
link_id: int,
collection_id: str,
) -> tuple[DeferredDispatch | None, DeferredDispatch | None]:
"""Return ``(assets_added_row, assets_removed_row)`` pending for this link+collection."""
result = await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.link_id == link_id,
DeferredDispatch.collection_id == collection_id,
DeferredDispatch.status == "pending",
DeferredDispatch.event_type.in_(["assets_added", "assets_removed"]),
)
)
added_row: DeferredDispatch | None = None
removed_row: DeferredDispatch | None = None
for row in result.all():
if row.event_type == "assets_added":
added_row = row
elif row.event_type == "assets_removed":
removed_row = row
return added_row, removed_row
async def _trim_queue_if_needed(
session: AsyncSession,
tracker_id: int,
) -> None:
"""Drop oldest pending rows beyond the per-tracker cap with a log row each.
Loads the parent tracker so the emitted event_log rows carry proper
``tracker_name``/``provider_id``/``provider_name`` and slot into the
dashboard's "by tracker" grouping — without these the drop rows show up
under an unattributed bucket and confuse the audit trail.
"""
rows = (await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.tracker_id == tracker_id,
DeferredDispatch.status == "pending",
).order_by(DeferredDispatch.fire_at.asc(), DeferredDispatch.id.asc())
)).all()
overflow = len(rows) - _MAX_PENDING_PER_TRACKER
if overflow <= 0:
return
_LOGGER.warning(
"Deferred queue for tracker %d exceeds cap (%d > %d); dropping %d oldest",
tracker_id, len(rows), _MAX_PENDING_PER_TRACKER, overflow,
)
tracker = await session.get(NotificationTracker, tracker_id)
tracker_name = tracker.name if tracker else ""
provider_id = tracker.provider_id if tracker else None
provider_name = ""
if tracker is not None and provider_id is not None:
provider = await session.get(ServiceProvider, provider_id)
if provider is not None:
provider_name = provider.name
for row in rows[:overflow]:
await _mark_dropped(
session, row,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
reason="queue_overflow",
)
# ---------------------------------------------------------------------------
# Enqueue (called from dispatch sites when gate returns QUIET_HOURS)
# ---------------------------------------------------------------------------
async def defer_event(
session: AsyncSession,
*,
event: ServiceEvent,
user_id: int | None,
tracker_id: int,
link_id: int,
event_log_id: int | None,
fire_at: datetime,
) -> str:
"""Persist a deferred dispatch (or coalesce into an existing one).
Caller is responsible for committing the session. Returns one of:
* ``"inserted"`` a fresh DeferredDispatch row was created.
* ``"merged"`` coalesced into an existing row (union or partial cancel).
* ``"cancelled"`` the new event fully cancelled an existing pending one
(add-then-remove or remove-then-readd of the same asset IDs). Both sides
are gone after this call.
* ``"non_deferrable"`` event type is wall-clock; caller should drop it
with a ``"suppressed_quiet_hours_nondeferrable"`` event_log row.
"""
event_type = event.event_type.value
if not is_deferrable(event_type):
return "non_deferrable"
fire_at_utc = fire_at.astimezone(timezone.utc) if fire_at.tzinfo else fire_at.replace(tzinfo=timezone.utc)
# Asset events get set-merging across the same link+collection. Everything
# else just gets a new row — those events aren't naturally cancellable.
if event_type in ("assets_added", "assets_removed"):
added_row, removed_row = await _find_pending_asset_rows(
session, link_id, event.collection_id,
)
if event_type == "assets_added":
action, upd_added, upd_removed = _coalesce_assets_added(
event, added_row, removed_row,
)
else:
action, upd_added, upd_removed = _coalesce_assets_removed(
event, added_row, removed_row,
)
# Apply pending updates. ``status="cancelled"`` rows are deleted
# outright so the drain doesn't see them.
fully_cancelled = False
for row in (upd_added, upd_removed):
if row is None:
continue
if row.status == "cancelled":
await session.delete(row)
fully_cancelled = True
else:
session.add(row)
if action == "insert":
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# action == "merge" — either updated existing or fully cancelled.
return "cancelled" if fully_cancelled and (upd_added is None or upd_added.status == "cancelled") and (upd_removed is None or upd_removed.status == "cancelled") else "merged"
# Non-asset event: no coalescing, fresh row.
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# ---------------------------------------------------------------------------
# Drain (called by APScheduler date job at quiet_hours_end_at)
# ---------------------------------------------------------------------------
async def drain_deferred_due(now: datetime | None = None) -> dict[str, int]:
"""Dispatch all pending DeferredDispatch rows whose ``fire_at <= now``.
Re-resolves link/target/configs against current DB state so config edits
between suppression and drain time take effect. Returns a small stats
dict for logging.
Implementation note: rows are *re-fetched* by id inside each per-tracker
session rather than carried across session boundaries. Carrying a row
instance to a new session and calling ``session.add(row)`` on a detached
PK-bearing instance triggers an INSERT (collision with the existing PK)
on flush a class of bug that's invisible until the first session
closes, hence the up-front re-fetch.
"""
now_utc = (now or datetime.now(timezone.utc))
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=timezone.utc)
stats = {"fired": 0, "dropped": 0, "rescheduled": 0, "errors": 0}
engine = get_engine()
async with AsyncSession(engine) as session:
# Only pull the row identity + grouping key. Loading the full ORM
# objects in a session that's about to close just wastes work — we
# re-fetch fresh attached instances in the per-tracker session below.
ident_rows = (await session.exec(
select(DeferredDispatch.id, DeferredDispatch.tracker_id).where(
DeferredDispatch.status == "pending",
DeferredDispatch.fire_at <= now_utc,
).order_by(DeferredDispatch.fire_at.asc())
)).all()
if not ident_rows:
_LOGGER.debug("drain_deferred_due: no pending rows due")
return stats
_LOGGER.info(
"Draining %d deferred dispatches due at %s",
len(ident_rows), now_utc.isoformat(),
)
# Group by tracker so a single per-tracker session can re-fetch its rows
# (attached) and re-resolve link state once.
ids_by_tracker: dict[int, list[int]] = {}
for row_id, tracker_id in ident_rows:
if row_id is None:
continue
ids_by_tracker.setdefault(tracker_id, []).append(row_id)
from .watcher import _get_telegram_caches
from .http_session import get_http_session
url_cache, asset_cache = await _get_telegram_caches()
shared_session = await get_http_session()
dispatcher = NotificationDispatcher(
url_cache=url_cache, asset_cache=asset_cache, session=shared_session,
)
for tracker_id, row_ids in ids_by_tracker.items():
async with AsyncSession(engine) as session:
tracker = await session.get(NotificationTracker, tracker_id)
# Re-fetch rows freshly attached to THIS session.
rows = (await session.exec(
select(DeferredDispatch).where(DeferredDispatch.id.in_(row_ids))
)).all()
if tracker is None or not tracker.enabled:
# Tracker deleted or disabled between defer and drain — drop
# all pending rows for it. Disable matches the live-path
# invariant (watcher / webhooks / scheduled_dispatch all
# short-circuit when ``tracker.enabled`` is False).
reason = "tracker_removed" if tracker is None else "tracker_disabled_after_defer"
for row in rows:
await _mark_dropped(
session, row,
tracker=tracker, reason=reason,
)
stats["dropped"] += 1
await session.commit()
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
provider_config = dict(provider.config) if provider else {}
provider_id = provider.id if provider else tracker.provider_id
provider_name = provider.name if provider else ""
app_tz = await get_app_timezone(session)
# Reload current link state. Broadcast links emit ONE entry per
# child target sharing the SAME parent ``link_id`` — a plain
# ``{link_id: ld}`` dict would silently drop N-1 children. The
# drain dispatches to every expanded entry for the parent.
link_data = await load_link_data(session, tracker_id)
link_by_id: dict[int, list[dict[str, Any]]] = {}
for ld in link_data:
key = ld.get("link_id")
if key is None:
continue
link_by_id.setdefault(key, []).append(ld)
for row in rows:
try:
await _process_row(
session, row, tracker, provider_id, provider_name,
provider_config, app_tz, link_by_id, dispatcher, stats,
)
except Exception as err: # noqa: BLE001 — keep draining other rows
_LOGGER.exception(
"Drain failed for deferred dispatch id=%s: %s", row.id, err,
)
stats["errors"] += 1
await session.commit()
_LOGGER.info("Drain complete: %s", stats)
return stats
async def _mark_dropped(
session: AsyncSession,
row: DeferredDispatch,
*,
tracker: NotificationTracker | None = None,
tracker_name: str = "",
provider_id: int | None = None,
provider_name: str = "",
reason: str,
) -> None:
"""Record a drop on the deferred row and emit a follow-up event_log entry.
``tracker``/``tracker_name``/``provider_id``/``provider_name`` populate
the new event_log row's owner/provider columns so the dashboard "by
tracker" grouping works for the drop path. Without these the row would
have empty strings and slot into the "unknown" bucket.
"""
if tracker is not None:
tracker_name = tracker_name or tracker.name
if provider_id is None:
provider_id = tracker.provider_id
payload = row.event_payload if isinstance(row.event_payload, dict) else {}
row.status = "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=payload.get("collection_name", ""),
assets_count=int(payload.get("added_count", 0))
or int(payload.get("removed_count", 0)),
details={
"dispatch_status": "deferred_then_dropped",
"reason": reason,
"original_event_log_id": row.event_log_id,
"provider_type": payload.get("provider_type", ""),
},
))
async def _process_row(
session: AsyncSession,
row: DeferredDispatch,
tracker: NotificationTracker,
provider_id: int,
provider_name: str,
provider_config: dict[str, Any],
app_tz: str,
link_by_id: dict[int, list[dict[str, Any]]],
dispatcher: NotificationDispatcher,
stats: dict[str, int],
) -> None:
"""Drain a single row: re-resolve link, re-evaluate gate, dispatch.
``link_by_id`` maps parent link_id list of expanded entries (one per
broadcast child, or a single-element list for regular targets). Every
entry produces its own target_config so a broadcast deferred row fans
out to all current children at drain time.
"""
expanded = link_by_id.get(row.link_id)
if not expanded:
# Link removed/disabled between defer and drain.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="link_removed",
)
stats["dropped"] += 1
return
# Every expanded entry for a parent link shares the same tracking_config,
# so the gate decision and ``apply_tracking_display_filters`` shaping are
# made once. Only the target_configs differ across children.
tc = expanded[0].get("tracking_config")
event = deserialize_event(row.event_payload)
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="event_type_disabled_after_defer",
)
stats["dropped"] += 1
return
if outcome.reason is GateReason.QUIET_HOURS and outcome.quiet_hours_end_at is not None:
row.fire_at = outcome.quiet_hours_end_at
session.add(row)
stats["rescheduled"] += 1
try:
from .scheduler import schedule_deferred_drain
schedule_deferred_drain(outcome.quiet_hours_end_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to reschedule drain for %s", outcome.quiet_hours_end_at,
)
return
shaped = apply_tracking_display_filters(event, tc)
if shaped is None:
# ``notify_favorites_only`` (or another display filter) dropped every
# asset from the event. Inconsistent earlier behavior swallowed this
# silently; we now route through the same "dropped + event_log"
# pathway as link_removed so the dashboard shows why.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="filtered_after_defer",
)
stats["dropped"] += 1
return
# Build one target_config per expanded child (regular targets → length 1;
# broadcast → length N children).
target_configs: list[TargetConfig] = []
for ld in expanded:
tmpl = ld.get("template_config")
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld.get("template_slots"),
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=(tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y"),
provider_api_key=provider_config.get("api_key") or provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", "") or provider_config.get("url", ""),
receivers=ld["receivers"],
))
# Per-row timeout — a single hanging remote call (Telegram outage, slow
# SMTP) must not stall the rest of the queue.
try:
results = await asyncio.wait_for(
dispatcher.dispatch(shaped, target_configs),
timeout=_DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
_LOGGER.warning(
"Drain dispatch for row %s timed out after %ds",
row.id, _DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
results = [{"success": False, "error": f"timeout after {_DRAIN_DISPATCH_TIMEOUT_SECONDS}s"}]
success = any(r.get("success") for r in results)
row.status = "fired" if success else "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
if success:
stats["fired"] += 1
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "delivered_after_quiet_hours",
"original_event_log_id": row.event_log_id,
"deferred_for_seconds": int(
(row.fired_at - row.created_at).total_seconds()
),
"provider_type": event.provider_type.value,
},
))
else:
stats["dropped"] += 1
first_err = next((r.get("error") for r in results if not r.get("success")), "unknown")
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "deferred_then_failed",
"reason": str(first_err)[:200],
"original_event_log_id": row.event_log_id,
"provider_type": event.provider_type.value,
},
))
# ---------------------------------------------------------------------------
# Startup: reschedule pending drain jobs found in the DB
# ---------------------------------------------------------------------------
async def load_pending_drain_jobs() -> int:
"""At startup, scan ``DeferredDispatch`` for pending rows and (re)schedule drains.
Rows whose ``fire_at`` already passed get a single immediate-fire job; the
rest get one job per distinct ``fire_at`` (minute-rounded) so all rows
sharing a window end share a drain.
"""
from .scheduler import schedule_deferred_drain
engine = get_engine()
async with AsyncSession(engine) as session:
rows = (await session.exec(
select(DeferredDispatch.fire_at).where(
DeferredDispatch.status == "pending",
)
)).all()
if not rows:
return 0
unique_fire_ats: set[datetime] = set()
for fa in rows:
if isinstance(fa, datetime):
unique_fire_ats.add(fa.astimezone(timezone.utc) if fa.tzinfo else fa.replace(tzinfo=timezone.utc))
for fa in unique_fire_ats:
schedule_deferred_drain(fa)
_LOGGER.info(
"Loaded %d pending deferred dispatches; scheduled %d drain job(s)",
len(rows), len(unique_fire_ats),
)
return len(unique_fire_ats)

Some files were not shown because too many files have changed in this diff Show More