Compare commits

...

45 Commits

Author SHA1 Message Date
alexei.dolgolyov 6877c4d272 chore: release v0.10.0
Release / test-backend (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
2026-06-05 21:04:37 +03:00
alexei.dolgolyov d01e519925 chore: add vex semantic-search config
Check in the .vex.toml that enables semantic embeddings, call-graph,
BM25, and auto-update for the vex code-search index.
2026-06-05 21:02:04 +03:00
alexei.dolgolyov 11593eaa7c fix(ci): pin aiohttp<3.14 in backend test deps
aioresponses 0.7.8 (latest) does not pass the stream_writer keyword
argument that aiohttp 3.14 made required on ClientResponse, so every
aioresponses-mocked HTTP test fails to construct a response. core
declares aiohttp>=3.9 with no upper bound, so CI floated to 3.14.0 and
the backend job would break on the next run. Pin <3.14 in the test
install in both build.yml and release.yml until aioresponses ships an
aiohttp-3.14-compatible release.
2026-06-05 21:01:44 +03:00
alexei.dolgolyov 8065e6effa feat(immich): multi-time-point scheduling for scheduled/periodic/memory
Replace the single comma-separated text box with an add/remove list of
native HH:MM pickers for the Immich scheduled-assets, periodic-summary,
and memory slots. The backend already stored comma-separated *_times and
scheduled one cron job per time; this makes entering several times per
day discoverable and hardens the read/write path.

Backend:
- services/time_list.py: normalize_time_list (validate / dedup / sort /
  cap at 24, raising TimeListError) + lenient parse_hhmm_list; the
  scheduler now uses the shared parser (drops its private copy).
- tracking_configs API normalizes *_times on every write (422 on bad
  input) and rejects enabling a slot whose times list is empty.
- scheduler warns when an enabled slot has zero or dropped fire times,
  restoring the observability lost with the old per-call warning.

Frontend:
- TimeListEditor.svelte: add/remove native time rows, dedupe + sort on
  emit, per-day cap, collapses on-screen duplicates, aria-labelled rows;
  syncs from the value prop only on external changes (untrack guard) so
  keyboard entry isn't clobbered mid-edit.
- Descriptor-driven save guard: an enabled feature section must have at
  least one time.
- i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList.

Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and
the enabled-implies-times validation.
2026-05-29 14:57:41 +03:00
alexei.dolgolyov e2c17dd343 chore: release v0.9.0
Release / test-backend (push) Successful in 2m16s
Release / release (push) Successful in 1m3s
2026-05-28 15:33:00 +03:00
alexei.dolgolyov 9aada75381 fix(tests): clear diagnostic_mode _bg_tasks between cases
test_fallback_task_retained_until_fire asserts len(_bg_tasks) == 1, but
the set carries pending tasks from earlier tests' fallback schedules, so
the assertion saw the accumulated count instead. Drop the references (no
.cancel() — the tasks belong to closed loops, cross-loop cancel raises
RuntimeError on the next test's setup).
2026-05-28 15:26:11 +03:00
alexei.dolgolyov 6a8f374678 feat: observability, per-receiver Telegram options, oversized-video fallback
Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
2026-05-28 15:19:31 +03:00
alexei.dolgolyov 85a8f1e71c chore: release v0.8.2
Release / test-backend (push) Successful in 2m20s
Release / release (push) Successful in 1m40s
2026-05-22 22:54:00 +03:00
alexei.dolgolyov 2d59a5b994 fix: production-readiness hardening from full-codebase review
Apply six isolated, low-risk fixes surfaced by the parallel
production-readiness review (backend, frontend, security, perf,
UI/UX, bugs+features).

Backend
- Mask access_token in provider GET responses and drop it on edit
  when carrying the *** placeholder — fixes plaintext leak of HA
  long-lived tokens (security H-1). Centralized via
  PROVIDER_SECRET_FIELDS so all call sites stay in sync (C-5).
- Hold HA status-change tasks in a module-level set with a
  done_callback — asyncio.create_task only keeps weak refs and
  the task could be GC'd before its row was written (C-1).
- Roll back the request session in the Telegram-webhook catch-all
  so a handler exception cannot leak uncommitted writes into the
  next request (C-2).
- Bail before reading the 1 MiB webhook body when the Gitea
  provider has no secret configured or the request has no
  signature header. For the generic webhook with bearer_token
  auth, verify the Authorization header before the body read.
  Closes the pre-auth resource-exhaustion amplifier (C-3).

Frontend
- Add supportsAutoOrganize capability to ProviderDescriptor and
  consume it from RuleEditor instead of `provider.type !== 'immich'`,
  bringing the last action-rule editor under CLAUDE.md rule 8
  (no provider-type hardcoding in components).
- Snackbar: add role="region" + per-toast role/aria-live/aria-atomic
  so screen readers announce success/error toasts.
- Sidebar nav: add aria-current="page" on the active link so the
  active state has an accessible name.
- New snackbar.region key in en + ru (locale parity preserved).

Out of scope for this commit (tracked in .claude/reviews/README.md
ship-blocker list): secret encryption at rest, JWT cookie move,
Alembic adoption, webhook idempotency, deferred-dispatch crash
window, persisted Telegram update watermark, bridge_self counter
lock — each needs more than a mechanical edit.
2026-05-22 22:47:20 +03:00
alexei.dolgolyov a20635a657 chore: sync .facts-sync.json with claude-code-facts@cfdafa9
Both pending suggestions (venv install for monorepos + hatchling
METADATA workaround) were applied directly to the facts repo in
commit cfdafa9. Queue file removed since nothing pending.
2026-05-16 19:59:40 +03:00
alexei.dolgolyov d7c48b06ee ci: isolate test backend install in venv
Release / test-backend (push) Successful in 2m1s
Release / release (push) Successful in 2m3s
The persistent Gitea runner caches the setup-python toolcache between
runs. A previous run that produced wheels with broken metadata (no
Version field in METADATA) left a notify-bridge-server install with
no RECORD file in site-packages. The next run hits:

  Found existing installation: notify-bridge-server None
  error: uninstall-no-record-file

pip refuses to uninstall (no RECORD) and refuses to overlay (it tries
to uninstall first). Switching from a system-pip install into the
toolcache to an isolated /tmp/venv per run sidesteps the leak — each
CI run starts with empty site-packages.

Same change to build.yml and release.yml so the pre-merge gate and
the release-gate both run the same setup.
2026-05-16 18:33:41 +03:00
alexei.dolgolyov 66f152ef2c fix(tests): green pytest gate for v0.8.1
Release / test-backend (push) Failing after 8s
Release / release (push) Has been skipped
Four root causes blocked the CI test gate; all fixed minimally:

1. test_release_provider._allow_private_urls used setenv +
   importlib.reload(ssrf_mod). The reload permanently rebound
   _ALLOW_PRIVATE=True in the module; monkeypatch.setenv undid the
   env var on teardown but the module attribute stayed True for the
   rest of the session, masking every test_ssrf*/test_ssrf_hardening
   case (16 failures). Switched to monkeypatch.setattr on the module
   attribute directly — restored cleanly on teardown.

2. _FakeResponse in test_release_provider lacked the content_length
   attribute and a top-level read() method that the new size-cap
   guards in gitea.py consult before parsing (5 failures).

3. test_gate_quiet_hours_wins_over_event_type_flag was asserting the
   pre-refactor gate order. evaluate_event_gate now intentionally
   reports EVENT_TYPE_DISABLED before QUIET_HOURS so deferrable
   events with the event-type flag off get dropped immediately
   instead of being deferred and then silently discarded at drain
   time. Renamed the test and inverted the expectation.

4. resolve_version() returned 0.0.0+unknown in CI because
   pip-wheel-built hatchling distributions ended up with METADATA
   missing the Version field — importlib.metadata returned None.
   Added __version__ = "0.8.1" to notify_bridge_server/__init__.py
   as a third (always-available) candidate; resolve_version() now
   picks the max of (installed, package, source).
2026-05-16 18:25:51 +03:00
alexei.dolgolyov faaaa39f8a chore: release v0.8.1
Release / test-backend (push) Failing after 1m36s
Release / release (push) Has been skipped
2026-05-16 12:28:07 +03:00
alexei.dolgolyov 8651767112 feat: bridge_self bot commands — status, thresholds, reset, health
Adds bot commands for the bridge_self provider so operators can inspect
and manage bridge health from chat: /status, /thresholds, /reset, /health.
Includes Jinja2 templates for both locales, seed data, capability slots,
and a handler that exposes pending deferred backlog plus per-counter
reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
2026-05-16 03:43:48 +03:00
alexei.dolgolyov 10d30fc956 feat: production readiness — security, perf, bug fixes, bridge self-monitoring
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.

## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
  event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
  acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
  featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession

## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
  cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch

## Database
- UNIQUE indexes on service_provider.webhook_token,
  telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
  telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
  partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
  delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified

## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
  identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
  on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events

## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
  oauth/client_secret/webhook_secret/csrf in both header filter and
  template extras filter

## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
  $effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
  now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
  goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
  Telegram bot toggles

## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
  (wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
  HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
  event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
  procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
  test_planka_parser (6), test_immich_change_detector (6),
  test_backup_roundtrip (1)

## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
  failures), bridge_self_deferred_backlog (pending count crosses
  threshold), bridge_self_target_failures (consecutive 5xx/network
  failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
  provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
  thresholds (logged only) — wire to your own Telegram/Email/Matrix to
  get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
  with backfill migration, frontend descriptor (excluded from "create
  provider" wizard since auto-managed)

Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
  receive failure alerts

Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:16:49 +03:00
alexei.dolgolyov 22127e2a59 feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces:

Notifications (subscription):
- Long-lived WebSocket client (aiohttp ws_connect) with auth handshake,
  exponential-backoff reconnect, bounded event queue, and area-registry
  enrichment cached per (re)connect
- ServiceProvider ABC gains an optional `subscribe()` method for push-style
  providers; HomeAssistantServiceProvider uses it via a per-provider
  supervisor task started in the FastAPI lifespan
- 4 event types (state_changed, automation_triggered, call_service,
  event_fired), 4 default Jinja templates (en + ru), HA-specific
  tracker filters (entity_glob, domain_allowlist, exact entity ids)
- Extracted shared dispatch pipeline (api/webhooks.py → services/
  event_dispatch.py) so subscription and webhook ingest share the same
  event_log + deferred-dispatch + quiet-hours code path

Bot commands:
- /status, /entities [glob], /state <entity_id>, /areas
- Multi-command WS session so /status and /areas cost one handshake
- Sensitive-attribute blocklist (camera access_token, entity_picture, etc.)
  and 30-attribute cap to keep /state output safe and within Telegram's
  message size
- Error-message redaction strips URL userinfo before surfacing to chat

Frontend:
- HA descriptor with toggle ConfigField type (new) and tag-input filter
  mode for free-text glob/domain lists (new TagInput component)
- 15 command slots + 4 notification slots wired into the existing
  template-config UI
2026-05-13 14:31:56 +03:00
alexei.dolgolyov 90f958bdc6 fix(server): honor periodic_interval_days for Immich periodic summary
The APScheduler cron job fires daily at every entry in `periodic_times`,
and nothing in the dispatch path consulted `periodic_interval_days` or
`periodic_start_date` — so a configured 3-day interval still produced a
daily summary.

Gate the dispatch in `dispatch_scheduled_for_tracker` for kind=periodic
via `(today_in_app_tz - start_date).days % interval == 0`. Log a skip
with reason `interval_not_due` on non-firing days so operators can tell
suppressed-by-interval apart from other skip causes.
2026-05-13 12:14:18 +03:00
alexei.dolgolyov dec0839853 feat: on-watch stats scope selector (page vs all)
Adds an icon selector to the "On watch" provider deck letting users
choose between page-scoped stats (legacy) and full-corpus stats that
aggregate across every event matching the current filters. Backend
returns a new provider_event_counts map alongside the paginated events.
2026-05-12 14:12:59 +03:00
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
295 changed files with 34382 additions and 3524 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
+177
View File
@@ -0,0 +1,177 @@
# Feature Backlog
Curated feature ideas, narrowed from a brainstorming pass on 2026-05-13.
Order is **rough sequencing preference**, not strict priority — adjust as we go.
---
## 1. Quiet Hours — close the gaps in the existing system
**Reality check (verified 2026-05-13).** Quiet hours are already shipped under
the "deferred dispatch" name in v0.8.0. The pipeline lives at
`packages/server/src/notify_bridge_server/services/deferred_dispatch.py` with
helpers in `dispatch_helpers.py` and tests in
`tests/test_deferred_dispatch.py`. What exists:
- Per-tracking-config window: `tracking_config.quiet_hours_enabled`,
`quiet_hours_start`, `quiet_hours_end`.
- Per-link override: `notification_tracker_target.quiet_hours_start`,
`quiet_hours_end`.
- Smart coalescing (asset add + asset remove during a window cancels each
other out, set-union merge for repeated adds).
- Post-window drain via APScheduler one-shot date jobs.
- Wall-clock event types (`scheduled_message`) drop instead of deferring.
- Frontend status surface: `deferred`, `deferred_then_dropped`,
`deferred_then_failed`, with `deferred_until` and `deferred_for_seconds`
fields exposed in the event log.
**What's NOT there (the actual gaps):**
| Gap | Sketch |
| --- | --- |
| **Target-level windows** | Today, hours bind to the *watcher* (tracking config / link). Users naturally think of DND at the *destination* ("don't ping my phone at night, regardless of source"). New column on `notification_target` + dispatcher gate. |
| **Multiple windows per row** | Today is a single HH:MM range. Real schedules want weekday-evening + weekend-all-day. JSON list of windows. |
| **Days-of-week** | Same window every day. Need `days: ["mon", "tue", ...]` filter per window. |
| **Per-window timezone** | Uses the global app TZ. Multi-traveller / multi-target setups want per-window TZ. |
| **Silent mode** | Modes today are defer-or-drop. Telegram `disable_notification=true` ("send but don't ring") is a third useful mode. |
| **Per-receiver windows** | One bot → multiple chats, each potentially with its own DND. Today it's all-or-nothing per target. |
**Recommended cut for v1 of "extend quiet hours":**
- Add target-level quiet hours (new column `notification_target.quiet_hours_json`
= list of `{days, start, end, mode, tz}`).
- Modes: `drop`, `defer`, `silent`. `defer` reuses the existing
deferred-dispatch pipeline (just changes who decides). `silent` maps to
`disable_notification=true` for Telegram; other targets fall through to
normal send (or we treat `silent` as `defer` for non-Telegram targets — TBD).
- Dispatcher precedence: target window wins over link/tracking-config window
when both are configured. Document this explicitly.
- Frontend: new "Quiet hours" fieldset in the target editor (Aurora cassette
style). Reuses Timezone picker; new day-picker chip row.
- Skip days-of-week + multi-window in v1 if scope grows — ship the target-level
cut first, then iterate.
**Open questions.**
- How exactly do target / link / tracking-config windows combine? Proposal:
any window covering "now" wins (drop > defer > silent precedence).
- Should `silent` for non-Telegram targets degrade to normal send or to
defer? Defer is the safer default.
- Does the event log need a new status (`silenced` / `dropped_by_target_qh`)
to make precedence visible?
---
## 2. Immich Smart Actions (expand beyond Auto-Organize)
**What.** Extend the existing Smart Actions pattern (currently:
**Immich Auto-Organize**) with more rule-driven actions against the Immich API.
**Why.** Auto-Organize already proves the descriptor → rule editor → executor
pipeline. Adding actions is mostly authoring new executors + small UI rule
shapes, not new infra.
**Candidates (pick in this order).**
1. **Auto-favorite by person** — when an asset is detected containing person
X (or any of a set), mark it favorite.
2. **Auto-archive by age / album** — assets older than N days in a given
album get archived. Pair with a "dry-run shows count" UX like
Auto-Organize already has.
3. **Duplicate cluster nudge** — periodically run Immich's duplicate API and
send a digest notification with inline buttons ("review", "ignore for 30d").
Depends on inline-button work (see backlog item 4 dependencies).
4. **Share-link rotation** — for an album, regenerate the share link every N
days; notify with the new URL.
5. **Pending-delete review** — push a weekly digest of trash contents before
Immich's auto-purge fires.
**Shape.**
- Reuse the existing **action descriptor** layer
(`packages/core/src/notify_bridge_core/providers/actions.py`,
`action_executor.py`) and the frontend rule editor used by Auto-Organize.
- Each new action = (a) executor in core, (b) rule schema in the descriptor,
(c) frontend descriptor extension for the rule editor fields.
- Persist as `provider_actions` rows (already exists for Auto-Organize) with
a discriminator + JSON config.
**Open questions.**
- Does "auto-favorite by person" need a confirmation queue or run silently?
Default to silent + event_log entry.
- How do we surface "this action moved/changed X assets" in the dashboard?
Probably a per-action stat tile on the provider detail page.
---
## 3. Home Assistant Provider
**Full plan:** [feature-home-assistant.md](feature-home-assistant.md).
**One-line summary.** New WebSocket-based service provider with a 3-phase
ship: subscribe + dispatch (Phase 1), bot commands (Phase 2), HA service
calls as Smart Actions (Phase 3). Chosen over webhook ingest because
Phases 2 + 3 force a long-lived API connection anyway; consolidating on WS
avoids a refactor.
**Status:** planned, not started.
---
## 4. Block-Based Template Builder
**What.** A visual, drag-and-drop builder for notification and command
templates that compiles down to Jinja2. Lives alongside (not instead of) the
current `JinjaEditor`. Author can flip between views.
**Why.** The current Jinja editor is powerful but unforgiving. A block UI
lowers the floor for new users and provides a discovery surface for the
variables documented in `template_configs.py`.
**Shape.**
- Frontend-only feature for v1 — compiles to the same Jinja strings the
backend already accepts.
- Blocks: `Text`, `Variable`, `If`, `For`, `Link`, `Image`, `Icon`, `Caption`,
`Group` (HTML span/group). Each block knows its serialized Jinja
representation.
- Round-trip: variables, simple `{% if %}` / `{% for %}` blocks, and string
literals parse back to blocks; arbitrary Jinja stays in a "Raw" block that
the user can edit as text.
- Variable picker reads `get_template_variables(provider_type, slot)`. This is
the same data already shown in the template-help panel.
- Preview pane unchanged — reuses `services/sample_context.py` server
rendering.
- Toggle in the template editor: **Visual / Code**.
**Open questions.**
- Round-tripping arbitrary Jinja is hard. v1: parseable subset → blocks,
anything else → single Raw block. Show a banner explaining.
- Locale handling: same compiled Jinja, just authored per locale tab.
- Do we want a marketplace of pre-built block compositions? Out of scope for
v1 — bundle import/export is a separate backlog item.
---
## Recommended Sequencing
1. **Quiet Hours per Target** — small, isolated, immediate user value.
2. **Immich Smart Actions** — incremental on existing pattern; ship one
action at a time (start with auto-favorite by person).
3. **Home Assistant Provider** — multi-file, follows new-provider checklist;
biggest user-base expansion.
4. **Block-Based Template Builder** — largest frontend lift; benefits from
the variable-doc work that the other features will exercise.
Dependencies are loose — 1 and 2 are independent of 3 and 4. The block
builder pairs nicely with Home Assistant because HA's rich context surfaces
the value of an easier authoring UX.
---
## Decision log
- **2026-05-13** — Backlog seeded with these four items selected from a
broader brainstorm. Not started.
+284
View File
@@ -0,0 +1,284 @@
# Home Assistant Provider — Implementation Plan
> Status: **planned, not started**. Sequencing: third item on the backlog
> (see [feature-backlog.md](feature-backlog.md)).
> Last updated: 2026-05-13.
## Decision: WebSocket subscription, not webhook
We considered three ingest modes (webhook automation, WebSocket subscription,
hybrid). The WebSocket route is chosen as the architectural foundation because
the medium-term roadmap forces it anyway:
| Phase | Capability | Needs API access? |
| --- | --- | --- |
| 1 | Subscribe to events, emit notifications | Read (event stream) |
| 2 | Bot commands (`/state`, `/entities`, `/areas`) | Read (REST or WS get_states) |
| 3 | Smart Actions (`light.turn_on`, scene activation) | Write (call_service) |
A webhook-only Phase 1 would still need a REST client by Phase 2 and a write
path by Phase 3 — net result is two client implementations + one event
pipeline refactor. WebSocket consolidates all three phases on one connection.
**Tradeoff (be honest):** WebSocket introduces a long-lived-connection pattern
this codebase does not have yet. Reconnect logic, missed-events-on-restart
gap, and a new shape on the `ServiceProvider` ABC are real costs. Phase 1 is
**not** shippable in one short session — plan for a multi-session build.
## Provider abstraction extension
The current `ServiceProvider` ABC
([packages/core/src/notify_bridge_core/providers/base.py](../../packages/core/src/notify_bridge_core/providers/base.py))
is poll-oriented: every provider implements `poll(collection_ids, state) →
(events, new_state)`. Webhook providers (Gitea, Planka, Webhook) satisfy this
by no-op'ing `poll` and shoving events in via `api/webhooks.py` instead.
Home Assistant fits neither cleanly. The plan:
1. Add an **optional** `async subscribe(emit) → None` method on the base ABC.
Default implementation raises `NotImplementedError`. Polling providers do
not override it. The scheduler / lifecycle layer (currently `services/watcher.py`)
gains a "subscription manager" branch that, for any provider whose class
overrides `subscribe`, starts a long-lived task instead of registering
a polling job.
2. `emit` is a callback `(event: ServiceEvent) → None` provided by the
subscription manager — it routes events to the dispatcher exactly like the
webhook handler does today. Keeping the dispatch path unchanged is the
point of this design.
3. Reconnect lives **inside** `subscribe`: the method is expected to be a
`while not cancelled: try connect; on drop, sleep with backoff, retry`
loop. The manager cancels the task on shutdown via the cooperative cancel
token used elsewhere.
This is a small, additive change to one ABC. No existing provider is
modified.
## Phase 1 — Subscribe + Dispatch (MVP)
### Scope
- Long-lived WebSocket connection to HA, authenticated with a long-lived
access token.
- Subscribe to the event bus with optional `event_type` filter (defaults to
`state_changed`).
- Translate HA events into `ServiceEvent` and dispatch via the existing
pipeline. Notifications go out exactly as they do today for any other
provider.
- Filter UI: entity-id glob list, domain allowlist (e.g. `light.*`,
`binary_sensor.*`), event-type allowlist. **Hard-required** to avoid the HA
firehose drowning the bridge.
- Connection test + entity listing via WS `get_states` (no REST client yet —
WS gives us both subscribe and read).
### Out of scope for Phase 1
- Bot commands → Phase 2.
- Service calls → Phase 3.
- Replay of events missed during disconnect (HA does not support this; we
document the gap and surface "reconnected after N seconds" in the event
log).
- Webhook-style ingestion (path-embedded token webhook receiver). If a user
prefers webhooks, we add it later as a second ingestion mode on the same
provider — out of scope for v1.
### Event types (v1)
| HA event | ServiceEvent type | Notification slot |
| --- | --- | --- |
| `state_changed` | `ha_state_changed` | `message_state_changed` |
| `automation_triggered` | `ha_automation_triggered` | `message_automation_triggered` |
| `call_service` | `ha_service_called` | `message_service_called` |
| (custom event types) | `ha_event_fired` | `message_event_fired` |
Default tracking config enables `state_changed` only — the others are loud
and opt-in.
### Context variables exposed to templates
Pulled directly from HA's `state_changed` payload, normalized:
- `entity_id``light.kitchen`
- `friendly_name``attributes.friendly_name` or fallback to `entity_id`
- `domain` — derived from `entity_id` before the dot
- `old_state``from_state.state`
- `new_state``to_state.state`
- `attributes` — dict of new-state attributes (raw)
- `device_class``attributes.device_class` if present
- `area``attributes.area_id` if present (best effort; only set if HA
exposes it via the area registry, which costs a `get_registry` WS call —
see "Open questions")
- `last_changed`, `last_updated` — ISO timestamps
- For non-`state_changed` events: `event_type`, `event_data` (full dict)
### File touch map (Phase 1)
**Core** (`packages/core/src/notify_bridge_core/`)
| Path | Action | Notes |
| --- | --- | --- |
| `providers/base.py` | Modify | Add optional `subscribe(emit)` ABC method (default `NotImplementedError`); add `HOME_ASSISTANT = "home_assistant"` to `ServiceProviderType` |
| `providers/capabilities.py` | Modify | Add `HOME_ASSISTANT_CAPABILITIES` + register |
| `providers/home_assistant/__init__.py` | Create | Export + register template variables |
| `providers/home_assistant/client.py` | Create | WebSocket client (auth, subscribe, get_states, call_service stub) |
| `providers/home_assistant/event_parser.py` | Create | HA event dict → `ServiceEvent` |
| `providers/home_assistant/provider.py` | Create | Class with `connect`, `disconnect`, `subscribe`, `list_collections` (entity list), `get_available_variables`, `get_provider_config_schema`, `test_connection`. `poll` raises NotImplementedError. |
| `templates/defaults/en/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/ru/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/loader.py` | Modify | Add to `PROVIDER_SLOT_FILE_MAP` |
| `templates/command_defaults/loader.py` | Modify | Stub entry — empty commands list for now |
| `templates/context.py` | Modify | HA context builder |
| `templates/validator.py` | Modify | Whitelist HA variable names |
**Server** (`packages/server/src/notify_bridge_server/`)
| Path | Action | Notes |
| --- | --- | --- |
| `services/watcher.py` *(or scheduler / lifecycle module that hosts polling)* | Modify | Add subscription-manager branch — for providers whose class overrides `subscribe`, start/stop long-running task instead of polling |
| `services/scheduler.py` | Verify | Confirm we cancel HA subscription on shutdown (graceful_shutdown_seconds path) |
| `api/template_configs.py` | Modify | `get_template_variables()` entry |
| `api/command_template_configs.py` | Modify | Sample ctx (minimal for Phase 1 — no commands) |
| `services/sample_context.py` | Modify | `_SAMPLE_CONTEXT["home_assistant"]` |
| `database/seeds.py` | Modify | Seed notification templates + default tracking config |
**Frontend** (`frontend/src/`)
| Path | Action | Notes |
| --- | --- | --- |
| `lib/providers/home-assistant.ts` | Create | Descriptor per CLAUDE.md rule 11 |
| `lib/providers/index.ts` | Modify | Register descriptor |
| `lib/locales/en.json` | Modify | `providers.typeHomeAssistant`, `gridDesc.providerHomeAssistant` |
| `lib/locales/ru.json` | Modify | Same |
**Tests**
| Path | Action |
| --- | --- |
| `packages/core/tests/providers/test_home_assistant_parser.py` | Create — HA payload → `ServiceEvent` |
| `packages/core/tests/providers/test_home_assistant_client.py` | Create — WS auth, subscribe, reconnect (use a fake server) |
| `packages/server/tests/test_home_assistant_subscription.py` | Create — subscription manager lifecycle, event flows through dispatcher |
### Frontend descriptor essentials
```text
type: "home_assistant"
defaultName: "Home Assistant"
icon: "home" (consider Lucide icon; HA logo if a custom asset exists)
hasUrl: true // base URL of HA (used to derive WS URL)
configFields:
- url: http(s)://homeassistant.local:8123
- access_token: long-lived access token (required)
- allowed_event_types: comma-separated, defaults to "state_changed"
eventFields: 4 checkboxes (state_changed, automation_triggered,
call_service, event_fired)
extraTrackingFields:
- entity_glob: tag input ("light.*", "binary_sensor.*_motion")
- domain_allowlist: tag input
collectionMeta: { label: "Entities", icon: "..." }
webhookBased: false // we are NOT webhook based
```
WS URL is derived: `wss://{host}/api/websocket` (or `ws://` for plain http
HA). Document this in the UI hint.
### Auth model
- **Long-lived access token** from HA (Profile → Long-Lived Access Tokens).
- Stored encrypted at rest via the same path the other providers use for
secrets (the bridge already has a secret-encryption helper — verify the
exact module name during implementation).
- WS auth handshake: connect → server sends `auth_required` → client sends
`{type: "auth", access_token: "..."}` → server replies `auth_ok` or
`auth_invalid`.
### Risks / open questions (Phase 1)
1. **Reconnect strategy.** Exponential backoff capped at 60s, jittered.
On reconnect, log a `connection_restored_after` event so the UI can
surface the gap. Document that HA does not support event replay.
2. **Area registry.** Pulling `area_id` for entities requires a separate
`config/area_registry/list` WS call. Decision needed: fetch once on
connect and cache, refetch on `area_registry_updated` event, or skip
`area` from the context entirely in v1. Recommendation: fetch on
connect, refetch on `area_registry_updated`, skip if it fails (best-effort).
3. **TLS verification for self-signed HA.** Homelab users often have
self-signed certs. Need a `verify_tls: bool` config field (default true)
and a clear warning when disabled. Same pattern as
`NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` for the SSRF case.
4. **Backpressure.** HA's `state_changed` can fire hundreds of events per
minute in a busy install. The subscription manager must drop or coalesce
if the dispatcher backlog grows beyond a threshold. Cheapest cut: a
bounded `asyncio.Queue` between WS receiver and dispatch — `put_nowait`
with overflow counter visible in the event log.
5. **Entity filter precedence.** Tracking-config has `collection_ids`
(entity_id list) and we want `entity_glob` + `domain_allowlist`. Decision:
if both `collection_ids` and globs are set, union them (any match passes).
Documented prominently in the tracker UI.
6. **Library choice.** `hass-client` is a Python WS client maintained by the
HA community; alternative is rolling our own with `websockets`. The
latter is ~150 LOC and has no external dependency surface. Recommendation:
roll our own. Re-evaluate if Phase 3 needs registry-aware service calls.
## Phase 2 — Bot Commands
Adds Telegram bot commands for HA tracking configs.
- `/status` — connection status, subscribed event count
- `/entities <glob>` — list matching entities + current state
- `/state <entity_id>` — full state + attributes for one entity
- `/areas` — area registry summary
- `/help`
These use the existing WS connection (no new client) via WS commands
`get_states`, `config/area_registry/list`. Template slots and command
template configs follow the same pattern as Gitea/Planka — see
[CLAUDE.md](../../CLAUDE.md) rule 7 / rule 11 for the full set of locations
that must be updated.
Out-of-scope for Phase 2: any command that mutates HA state.
## Phase 3 — Smart Actions (Service Calls)
A new action descriptor in the existing Smart Actions framework
([packages/core/src/notify_bridge_core/providers/actions.py](../../packages/core/src/notify_bridge_core/providers/actions.py)).
- Action type: `ha_call_service`
- Rule: trigger event → service call (e.g. "on motion event in
`binary_sensor.front_door` → call `light.turn_on` on `light.porch`")
- Executor uses the existing WS connection to send `call_service`.
This phase is gated behind explicit per-target authorization in the UI — HA
service calls can do anything the access token allows, including unlocking
doors. Default state: **disabled**, with a clear consent flow when enabling.
## Rough effort estimates
These are rough — sub-task discovery during Phase 1 will refine them.
| Phase | Estimate (focused work) |
| --- | --- |
| Phase 1 (subscribe + dispatch) | 23 sessions |
| Phase 2 (bot commands) | 1 session |
| Phase 3 (smart actions) | 12 sessions |
## When to start
Phase 1 work order, once you green-light it:
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
provider.
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
needed for these).
3. Subscription manager in `services/watcher.py` — integration test with the
fake provider from step 1.
4. Templates (en + ru), capabilities entry, validator whitelist.
5. Server: seeds, sample context, template_configs entry.
6. Frontend: descriptor, locale keys, i18n.
7. End-to-end smoke test against a real HA instance (homelab).
Backend restart cadence per the project rule: after **every** change in
`packages/server/` or `packages/core/`.
## Decision log
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
webhook for future-proofing toward Phases 2 + 3). Not started.
@@ -0,0 +1,435 @@
# Functional Review — Telegram, Immich, Logging (2026-05-28)
Snapshot review of three subsystems, with prioritised improvement candidates.
Pairs with [feature-backlog.md](feature-backlog.md) — items here are
infrastructure that unlocks several backlog features.
All citations are from the working tree at commit `85a8f1e` (master). Two
files (`packages/core/src/notify_bridge_core/notifications/telegram/client.py`,
`media.py`) had uncommitted changes at review time — see Telegram §
"In-flight work".
---
## 1. Telegram infrastructure
### Telegram — what works well
- Single chokepoint `TelegramClient`
([packages/core/src/notify_bridge_core/notifications/telegram/client.py](../../packages/core/src/notify_bridge_core/notifications/telegram/client.py))
covers text/photo/video/document/media-group, with 429-aware retry,
parse-error retry, file_id cache, multi-bot per-token instances,
polling + webhook modes, and bot-command registration.
- CLAUDE.md rule #6 satisfied for the production paths.
- Caption length, group sizing, parse-mode fallback all enforced.
### In-flight work
Byte-budget sub-chunking for media groups
(`TELEGRAM_MAX_GROUP_TOTAL_BYTES` in
[media.py](../../packages/core/src/notify_bridge_core/notifications/telegram/media.py))
with per-item fallback inside `_send_media_group`. Logic is coherent;
before commit, verify `_build_media_items` callers still match the new
signature (caption no longer injected at fetch time).
### Gaps, ranked by user-visible value
1. **No inline keyboards / `callback_query` handlers** — zero infra for
"Favorite / Archive / Dismiss" buttons on Immich notifications.
Biggest UX unlock; prerequisite for several Immich smart actions.
2. **No edit-in-place** (`editMessageText` not wrapped). Pairs naturally
with deferred dispatch / quiet hours coalescing — 5 separate
"asset added" messages become 1 edited message.
3. **`disable_notification` (silent send) not exposed** — already a
Telegram primitive; slots into the quiet-hours `silent` mode the
backlog already mentions.
4. **`message_thread_id` (forum topics)** — single field per receiver;
unblocks supergroup-with-topics users.
5. **Direct `TelegramClient(...)` constructions** in
[api/telegram_bots.py:314,394,404,412](../../packages/server/src/notify_bridge_server/api/telegram_bots.py)
bypass `get_telegram_client()` — violates CLAUDE.md rule #6 and
skips the shared file_id cache.
6. **Per-command authorization**`commands_enabled` is all-or-nothing
per chat; no per-command allowlist or admin gate.
7. **Long-message splitting**`send_message` silently truncates at
4096 ([client.py:492](../../packages/core/src/notify_bridge_core/notifications/telegram/client.py)).
8. **No parse-mode per target** — HTML hardcoded.
---
## 2. Immich
### Immich — what works well
- Mature polling pipeline: incremental delta-fetch via `updatedAfter`,
pending-asset tracking, fingerprint fast-path skip, fallback to full
fetch on count-decrease
([providers/immich/provider.py](../../packages/core/src/notify_bridge_core/providers/immich/provider.py)).
- Rich bot commands (status / albums / events / people / search / latest
/ random / favorites / summary / memory) with full asset context
(CLAUDE.md rule #10 satisfied).
- `auto_organize` action is well-shaped: AND person + smart-query union,
exclusions, type/date/favorite filters, 500-asset batched add,
idempotent diff against album asset_ids, dry-run, `ActionExecution`
log.
- Three scheduled features wired: periodic summaries, scheduled-asset
delivery, Memory/On-This-Day (with native Immich memory API + fallback).
### Highest-leverage candidates
1. **Webhook ingestion**`webhook_based=False` at
[capabilities.py:46](../../packages/core/src/notify_bridge_core/providers/capabilities.py).
Sub-second latency vs the current 5-min poll. New
`/api/webhooks/immich/{secret}` route + parser + capability flip.
2. **Share-link expiry monitoring + auto-rotate action** — links
silently break today; data is already fetched per event
([provider.py:541-569](../../packages/core/src/notify_bridge_core/providers/immich/provider.py)).
3. **Duplicate cluster digest** — Immich >= 1.100 `/api/duplicates` is
unused; pairs with inline buttons for "merge / ignore 30d".
4. **Auto-favorite by person** (already in backlog) — smallest delta on
the existing `auto_organize` executor.
5. **Per-person notification subscription** — tracker-config filter,
reuses existing `asset.people` data.
6. **Album auto-curation from Inbox** — date-based target album name,
move (not copy); needs the Immich move endpoint (currently we only
`add_assets_to_album`).
7. **Storage / job-queue alerts**`/api/server/stats` and `/api/jobs`
unused; lightweight poll + threshold = "disk full" / "transcoding
stalled" notifications.
8. **Smart-action infra polish** — descriptors are reusable, but the
rule editor is JSON-shaped, action-run statistics aren't aggregated,
and dry-run shows counts not the asset list. Address before adding 5
more action types.
---
## 3. Logging
### What's already in place
In [logging_setup.py](../../packages/server/src/notify_bridge_server/logging_setup.py):
- `dictConfig` with `JsonFormatter` (line-delimited JSON) toggleable via
`NOTIFY_BRIDGE_LOG_FORMAT=json`.
- `SecretMaskingFilter` redacts Telegram bot tokens + Authorization /
api_key / password / refresh_token across `msg`, `exc_text`,
`stack_info`.
- ContextVar-driven record factory injects `request_id`, `command`,
`chat_id`, `bot_id`, `dispatch_id` on every record. Text format:
`[req=- cmd=- bot=- chat=- disp=-]`.
- Per-module overrides via `NOTIFY_BRIDGE_LOG_LEVELS` env or DB
`AppSetting`. Live runtime patch via `apply_log_levels()` — no
restart.
- Noisy libs pre-quieted (sqlalchemy, aiohttp, apscheduler, urllib3,
asyncio, httpx, httpcore, PIL, uvicorn.access).
Plus:
- `EventLog` table with structured rows (event_type, status,
assets_count, details JSON, FKs to tracker/provider/action/
command_tracker/bot), `event_log_retention_days=30` default, daily
APScheduler cleanup `_cleanup_old_events`
([scheduler.py:332](../../packages/server/src/notify_bridge_server/services/scheduler.py)).
- Prometheus counter `notify_bridge_event_log_total{status,event_type}`.
- Frontend viewer with filters at
[api/status.py](../../packages/server/src/notify_bridge_server/api/status.py).
- `bind_log_context` actually used in: dispatcher (dispatch_id),
telegram_poller (bot/chat/command/request_id), webhook commands.
### Gaps, ordered by debug-pain payoff
1. **No FastAPI request-ID middleware.** `request_id_var` is set only
in webhook + Telegram poller paths. Every REST call from the SPA
logs as `req=-`. Tiny middleware (read `X-Request-Id` or
`uuid4()`, bind context, echo header) closes this whole-app blind
spot.
2. **`dispatch_id` is in log lines but NOT persisted on the `EventLog`
row.** Means you can find the failed row in the UI but can't grep
stderr for the matching `disp=...`. Stash it in `details.dispatch_id`
(no migration needed) — biggest cross-surface correlation win.
3. **HTTP access log is uvicorn default**
(`access_log=not _cfg.debug` at
[main.py:419](../../packages/server/src/notify_bridge_server/main.py)).
Doesn't include `request_id`, latency, user, status as structured
fields. Replace with a small `RequestLoggerMiddleware` that emits
`method`, `path`, `status`, `latency_ms`, `request_id`.
4. **Telegram media-group failures log richly but aren't linked to the
resulting `EventLog` row.** The dispatcher result-aggregation work
in flight is the right place to dump `errors[]` into
`EventLog.details.errors`.
5. **In-browser log access is missing.** EventLog rows are visible, but
raw logger output requires container/SSH access. A bounded
in-memory ring-buffer endpoint (admin-only, last N lines, filtered
by context fields) would mean ~90% of triage stays in the UI.
6. **No "diagnostic mode" UI.** The runtime `apply_log_levels()` is
great but only reachable through the app-settings JSON editor.
A "Debug for 15 minutes: `notify_bridge_core.notifications.telegram.client`"
button with auto-revert is a few-hours job.
7. **`EventLog.details` is freeform.** Frontend already destructures
`dispatch_status`, `deferred_until`, `deferred_for_seconds`,
`original_event_log_id`
([types.ts:238-261](../../frontend/src/lib/types.ts)). Define a
typed `EventLogDetails` per `event_type` (Pydantic at the boundary)
— prevents drift between providers.
8. **No log rotation**`StreamHandler(sys.stderr)` only. Fine in
containers, brittle on bare-metal. Optional `RotatingFileHandler`
opt-in via env.
9. **No slow-query / outbound-HTTP timing logs.**
`sqlalchemy.engine=WARNING` by default; no per-query duration log.
Same for outbound calls to Immich / Telegram. A
"duration_ms >= N" threshold logger would surface "why is this
dispatch slow" without flipping global DEBUG.
10. **Action dry-run output is logger-only.** Could be streamed into
the action editor.
11. **Poll-result not persisted.** Webhook payloads are logged
([api/webhook_logs.py](../../packages/server/src/notify_bridge_server/api/webhook_logs.py)),
but Immich/Google-Photos poll cycles emit no
"last poll: 0 changes / 245ms" row. A lightweight
`provider_poll_log` (small table or ring buffer) would answer
"is the poller actually running" without reading stderr.
---
## Recommended sequencing
| # | Item | Status | Why first |
| --- | --- | --- | --- |
| 1 | Request-ID middleware + persist `dispatch_id` on `EventLog` | **SHIPPED 2026-05-28** | Unlocks the rest of the debug story; ~2 hours combined |
| 2 | Finish in-flight Telegram byte-budget chunking + write `errors[]` into `EventLog.details` | **SHIPPED 2026-05-28** | Already half-done; aligns with #1 |
| 3 | Telegram inline keyboards + `callback_query` handler | not started | Prereq for several Immich smart actions |
| 4 | Telegram `disable_notification` + `message_thread_id` per target | **SHIPPED 2026-05-28** | Small, also feeds the open Quiet Hours v1 backlog item |
| 5 | Immich webhook ingestion | not started | 5-min → sub-second; biggest user-facing latency win |
| 6 | Immich share-link expiry + auto-rotate (using #3) | not started | Real silent-breakage today |
| 7 | Diagnostic-mode UI (live log-level toggle with auto-revert) | **SHIPPED 2026-05-28** | Shifts triage to the browser |
| 8 | Immich duplicate digest + auto-favorite by person | not started | Both ride on #3 |
Items 14 are infrastructure that unlocks 58. Items 1, 2, 4 also
smooth the Quiet Hours v1 / target-level windows that's top of the
backlog — worth landing before that feature so quiet hours can dispatch
through edited messages and silent sends from day one.
---
## Decision log
- **2026-05-28** — Review completed. Starting work on item #1
(request-id middleware + persist `dispatch_id` on `EventLog`).
- **2026-05-28** — Item #1 shipped. Summary of the change:
- New helpers in
[packages/core/src/notify_bridge_core/log_context.py](../../packages/core/src/notify_bridge_core/log_context.py):
`ensure_dispatch_id()` (reuse existing or mint a new
`disp:<12 hex>`) and `enrich_details_with_correlation(details)`
(shallow-copy a details dict and merge active `dispatch_id` /
`request_id` from the ContextVar snapshot).
- New `RequestContextMiddleware` in
[packages/server/src/notify_bridge_server/main.py](../../packages/server/src/notify_bridge_server/main.py)
that reads inbound `X-Request-Id` (charset/length validated, `:`
excluded so a client can't masquerade as a server-minted id),
falls back to `req:<12 hex>`, binds the value via
`bind_log_context`, and echoes it back as the response header.
Added LAST so it's the outermost middleware.
- Outer entry points now bind a `dispatch_id` via a thin wrapper
function (`check_tracker`, `dispatch_provider_event`,
`dispatch_scheduled_for_tracker`, `_process_row`, `run_action`).
All 10 `EventLog(...)` creation sites wrap their `details=`
payload in `enrich_details_with_correlation(...)`.
- Switched `NotificationDispatcher.dispatch` to use
`ensure_dispatch_id()` instead of inline `uuid.uuid4()`.
- New tests in
[packages/server/tests/test_request_correlation.py](../../packages/server/tests/test_request_correlation.py)
(12 tests) covering header echo, charset validation, prefix-
masquerade rejection, helper merge semantics. All 239 server
tests green.
- Reviewed by `python-reviewer` subagent (no CRITICAL/HIGH; 3 MEDIUM
and 1 LOW addressed: PEP 8 imports moved to top of main.py;
`RequestResponseEndpoint` type added to `dispatch`; `:` dropped
from the request-id charset; shallow-copy caveat documented).
- Live smoke verified: generated id `req:a9b9821f5aab` on plain
request; safe inbound `my-trace-abc123` echoed unchanged;
`disp:fake12345678` correctly replaced; watcher tick log lines now
show distinct `disp=disp:<hex>` per tracker check.
- **2026-05-28** — Item #2 shipped. Summary of the change:
- Confirmed the in-flight Telegram byte-budget media-group chunking
in
[telegram/client.py](../../packages/core/src/notify_bridge_core/notifications/telegram/client.py)
is complete (15/15 media-group tests pass). Deleted the now-unused
`split_media_by_upload_size()` from
[telegram/media.py](../../packages/core/src/notify_bridge_core/notifications/telegram/media.py).
- New module
[services/dispatch_summary.py](../../packages/server/src/notify_bridge_server/services/dispatch_summary.py)
with `summarize_dispatch_results()` (aggregator),
`attach_summary_in_place()` (in-session) and
`record_dispatch_summary_async()` (post-commit). Captures
`targets_attempted/succeeded/failed`, per-target `errors`,
media-group `media{delivered,skipped,failed}` counts and
`media_errors[]` from the new
`TelegramClient._send_media_group` partial-failure path.
Bounded: 20 errors / 20 media errors / 500-char message cap with
explicit `…[truncated]` marker.
- Wired at 4 dispatch sites:
- `event_dispatch.py`: accumulates per-target results across all
tracking-config groups, attaches summary in-session before
commit.
- `deferred_dispatch.py`: inlines summary into the new EventLog
row's `details` for both `delivered_after_quiet_hours` and
`deferred_then_failed` paths.
- `scheduled_dispatch.py`: inlines summary into the cron-fire
EventLog row's `details`.
- `watcher.py`: follow-up `record_dispatch_summary_async` in a
fresh session because the EventLog row was committed before
dispatch.
- Frontend type drift fixed:
[types.ts](../../frontend/src/lib/types.ts) gets new
`DispatchSummary`, `DispatchSummaryError`,
`DispatchSummaryMediaError` interfaces plus `dispatch_id` /
`request_id` / `dispatch_summary` keys on `EventLog.details`.
- New tests in
[tests/test_dispatch_summary.py](../../packages/server/tests/test_dispatch_summary.py)
(10 tests): empty/all-success/mixed/media-counts/sub-errors/
truncation/long-message-trim/in-place attach/no-results no-op/
malformed sub-error. All 249 server tests green.
- Reviewed by `python-reviewer` subagent (no CRITICAL; 2 HIGH + 3
MEDIUM addressed: `asyncio.CancelledError` re-raise in the
best-effort catch; late `from .dispatch_summary import …` calls
hoisted to top of each file; empty-results contract changed from
"zero-count summary attached" to "no key written"; truncation
marker upgraded to `…[truncated]` for operator clarity;
`flag_modified` comment tightened).
- Live smoke: backend restarts cleanly, watcher tick log lines
continue showing `disp=disp:<hex>` correlation, no startup
errors.
- **2026-05-28** — Item #4 shipped. Summary of the change:
- `TelegramReceiver` dataclass in
[receiver.py](../../packages/core/src/notify_bridge_core/notifications/receiver.py)
gains `disable_notification: bool = False` and
`message_thread_id: int | None = None`. New
`_coerce_telegram_thread_id` helper collapses Telegram's "general
topic" sentinels (`0`, negatives, blanks, bools) to `None` so the
Bot API just omits the field — matches the frontend's `<= 0 → unset`
behaviour.
- `TelegramClient`
([client.py](../../packages/core/src/notify_bridge_core/notifications/telegram/client.py))
gets a frozen `_SendOptions` + `_send_options_var` `ContextVar`
pattern for the deep media paths (`_upload_media`,
`_post_media_group`, `_send_from_cache`) that can't easily plumb
kwargs through. `send_notification` binds the var; the 3 deep
builders read it via `_apply_send_opts_to_payload` /
`_apply_send_opts_to_form`. `send_message` is a leaf and just
inlines its kwargs into the JSON body directly (no ContextVar
needed there).
- Dispatcher
([dispatcher.py](../../packages/core/src/notify_bridge_core/notifications/dispatcher.py))
passes `receiver.disable_notification` / `receiver.message_thread_id`
into `client.send_message(...)` and `client.send_notification(...)`.
- Frontend: new inline per-Telegram-receiver options panel in
[ReceiverSection.svelte](../../frontend/src/routes/targets/ReceiverSection.svelte)
triggered by a cog icon. Silent + thread-id indicators (bell-off
icon, `#N` badge) on the row when set. `+page.svelte` handlers
PUT the merged config to `/api/targets/{id}/receivers/{rid}`.
5 new i18n keys in `en.json` / `ru.json`.
- New tests in
[test_telegram_per_send_options.py](../../packages/server/tests/test_telegram_per_send_options.py)
— 19 tests: factory + thread-id coercion table (including bool
rejection and `0`/negative collapse), payload/form helper merge
semantics, bind/reset under exceptions, concurrent-task isolation
via `asyncio.gather`, end-to-end `send_message` payload assertions.
All 270 server tests green.
- Reviewed by `python-reviewer` subagent (no CRITICAL; 2 HIGH + 1
MEDIUM + 1 LOW addressed: dead ContextVar bind in `send_message`
removed in favor of inline kwarg injection; re-entrant bind from
`send_notification → send_message` auto-resolved by the same fix;
`message_thread_id=0` collapse aligns backend with frontend;
`_coerce_telegram_thread_id` rejects `bool` input).
- Live smoke: backend restarts cleanly, no errors in startup log.
- **2026-05-28** — Holistic `code-reviewer` pass over the full session
diff (Features 1+2+4+7) caught a real HIGH that the per-feature
Python-narrow reviews missed: ``summarize_dispatch_results`` in
Feature 2 was reading the wrong dict shape. The dispatcher's
``_aggregate_results`` wraps per-receiver dicts under
``result["results"]`` and renames the Telegram media counts to
``media_delivered_count`` / ``media_skipped_count`` /
``media_failed_count``. The summarizer was reading the top-level
``delivered_count``, which is always absent in production aggregated
output — meaning the ``dispatch_summary.media`` block was silently
zero / missing for every real dispatch, and the ``media_errors``
list never populated. The unit tests passed because they
hand-constructed leaf-shaped dicts that masked the wrong-shape
read. Fixed in
[dispatch_summary.py](../../packages/server/src/notify_bridge_server/services/dispatch_summary.py)
by drilling into ``result["results"]`` per-receiver leaves and
preferring ``media_*_count`` field names with fallback to the
top-level names. Receiver index added to ``media_errors`` entries
when drilling. New integration tests in
[test_dispatch_summary.py](../../packages/server/tests/test_dispatch_summary.py)
use the real dispatcher envelope so a future shape regression fails
loudly. Also addressed MEDIUM findings: ``attach_summary_in_place``
/ ``record_dispatch_summary_async`` now skip when a caller has
pre-set ``dispatch_summary`` (mirrors the "caller wins" rule in
``enrich_details_with_correlation``); ``ReceiverSection.svelte``
props for the Telegram options panel are now optional + gated
internally so the component stays portable; TS type for
``editingReceiverOptions.message_thread_id`` is ``number | ''``
with proper coercion in ``openEditReceiver``. 294/294 server tests
green; backend restarts clean.
- **2026-05-28** — Item #5 NOT shipped. Reason: Immich has no
outbound webhook feature. The closest thing is `POST /sync/stream`
(a server-streaming sync API designed for first-party Immich
clients), and adopting it would (a) take 1-2 days of new
subscription-manager infrastructure, (b) couple us to an API with no
third-party stability contract, and (c) deliver 5-min → sub-second
latency on photo notifications which is rarely critical. If
someone later actually needs lower latency, dropping the default
``scan_interval`` is a 5-minute alternative that gets 80% of the
win for 1% of the cost. Skipped in favour of #7.
- **2026-05-28** — Item #7 shipped. Summary of the change:
- New service module
[services/diagnostic_mode.py](../../packages/server/src/notify_bridge_server/services/diagnostic_mode.py)
with `set_diagnostic` / `revert_diagnostic` / `revert_all` /
`list_active`. State is in-memory only — restart wipes overrides
(`setup_logging` re-applies the DB baseline at boot). Modules go
through an allowlist (`notify_bridge_*`, `sqlalchemy`, `aiohttp`,
`apscheduler`, `urllib3`, `httpx`, `httpcore`, `asyncio`, `PIL`,
`uvicorn`, `starlette`, `fastapi`) so a button press can't flip
root. Duration clamped to `[1, 240]` minutes. Baseline derivation
walks the dotted parents so
`sqlalchemy.engine.Engine` correctly inherits `sqlalchemy.engine`
→ WARNING rather than falling through to root.
- 3 new admin-only endpoints under `/api/settings/diagnostic-mode`
in
[api/app_settings.py](../../packages/server/src/notify_bridge_server/api/app_settings.py):
`GET` (list active), `POST` (activate, 400 on invalid input),
`DELETE /{module:path}` (manual revert, 404 if not active).
- Auto-revert uses APScheduler's date trigger with `misfire_grace_time=60`,
falling back to a strongly-referenced asyncio task (stored in a
module-level set with `add_done_callback(discard)`) when the
scheduler isn't running. `_expire_callback` re-reads `log_levels`
from the DB at fire time, so an admin who edits overrides mid-window
sees the new baseline restored — not a stale snapshot.
- `revert_all` is wired into the FastAPI lifespan shutdown in
[main.py](../../packages/server/src/notify_bridge_server/main.py)
so a clean stop / hot-reload leaves the world tidy.
- New frontend
[DiagnosticsCassette.svelte](../../frontend/src/routes/settings/DiagnosticsCassette.svelte)
sits below `LoggingCassette` in the settings page. Quick-pick
module dropdown + custom-text fallback, duration chip group (5m /
15m / 30m / 1h / 2h), Activate button. Active list with countdown
updated by a 1s ticker; resyncs from the backend every 30s based
on elapsed time (not modulo-of-now, which the prior version had
wrong). Manual revert via undo-icon button on each row.
- 15 new i18n keys in `en.json` / `ru.json`.
- 20 new tests in
[test_diagnostic_mode.py](../../packages/server/tests/test_diagnostic_mode.py)
— service-module unit tests + 4 FastAPI smoke tests via
`dependency_overrides[require_admin]` exercising the router /
path converter / HTTPException paths. All 290 server tests green.
- Reviewed by `python-reviewer` subagent (no CRITICAL; 3 HIGH +
3 MEDIUM addressed: fallback task retention in a module-level set
to prevent GC; prefix-walk for `_baseline_for` so sub-loggers
inherit parent defaults; `revert_all` wired into lifespan
shutdown; `list_active` now sweeps expired entries; DB
`log_levels` re-read at revert time instead of snapshot at
activation; frontend resync uses elapsed time. LOW items
addressed: scheduler-unavailable paths log at DEBUG instead of
silently passing; test cleanup of dead `_MIN_DURATION_MINUTES`
mutation).
- Live smoke: backend restarts cleanly, no errors in startup log.
+89
View File
@@ -0,0 +1,89 @@
# Production-Readiness Review — service-to-notification-bridge v0.8.1
**Date:** 2026-05-22 **Scope:** entire codebase (~70k LOC, 312 files)
**Branch:** master @ a20635a **Reviewers:** 6 parallel specialised agents
## Verdict
**Ship-readiness: nearly there.** The product is in materially better shape than a typical pre-1.0 — every security baseline is in place (sandboxed Jinja2, bcrypt+JWT, SSRF guard with DNS-rebinding mitigation, secret masking, signed webhooks, non-root Docker, owner-scoped queries) and the feature set is mature (deferred dispatch, quiet hours, fan-out caps, 429 backoff, Prometheus metrics). No CRITICAL security findings exist.
The work that *should* block shipping to wider users is concentrated in **three buckets**: (1) a handful of correctness defects that surface only under load or restart (duplicate-send class), (2) two secret-handling gaps (HA token returned cleartext, bot tokens/SMTP passwords unencrypted at rest), and (3) the schema-management story (`create_all` on boot + 1880-line hand-rolled migration script with no Alembic).
## Reports
| Axis | File | Findings | Top hit |
|---|---|---|---|
| Backend (Python) | [backend-review.md](backend-review.md) | 5C / 15H / 18M / 10L | `asyncio.create_task` GC in HA status logger |
| Frontend (TS/Svelte) | [frontend-review.md](frontend-review.md) | 2C / 10H / 19M / 7L | JWT access+refresh in `localStorage` |
| Security | [security-review.md](security-review.md) | 0C / 2H / multiple M | HA `access_token` not masked on `GET /providers/{id}` |
| Performance + DB | [performance-db-review.md](performance-db-review.md) | 3C / 7H / 10M / 10L | `SQLModel.metadata.create_all` on every boot |
| Bugs + features | [bugs-features-review.md](bugs-features-review.md) | 3C / 13H / 12M / 3L + 25 features | Webhook redelivery has no idempotency |
| UI/UX | [ui-ux-review.md](ui-ux-review.md) | ~33 across 13 axes | Five overlapping glass-card abstractions |
## Ship blockers (must fix before wider rollout)
Cross-cutting top 12 — verified across all six reviews:
1. **HA `access_token` returned in plaintext** on `GET /api/providers/{id}` — not in mask list. *(Security H-1, [providers.py:399-405](packages/server/src/notify_bridge_server/api/providers.py#L399))*
2. **Secrets unencrypted at rest** — Telegram bot tokens, SMTP passwords, HA tokens, webhook secrets stored as plain text in SQLite. Disk/snapshot/backup theft = full credential set. *(Security H-2)*
3. **Frontend JWT access + refresh in `localStorage`** — any future XSS exfiltrates the session in one call. Move to httpOnly cookie. *(Frontend C-1)*
4. **`asyncio.create_task` fire-and-forget** in `ha_subscription._on_status_change` — task may be GC'd before completion. *(Backend C-1, [ha_subscription.py:249](packages/server/src/notify_bridge_server/services/ha_subscription.py#L249))*
5. **Pre-auth 1 MiB body read** on Gitea + generic webhooks — DoS amplifier. Verify `X-Hub-Signature` before reading body. *(Backend C-3, [webhooks.py:167](packages/server/src/notify_bridge_server/api/webhooks.py#L167) + 449)*
6. **No webhook idempotency** — Gitea/Planka/generic don't dedupe by `X-Gitea-Delivery` / equivalent. Replays = duplicate sends. *(Bugs C-1)*
7. **Deferred-dispatch crash window**`dispatch()` returns before `session.commit()`; restart re-fires. Wrap in idempotent "claim → send → ack" with a unique constraint. *(Bugs C-2)*
8. **Telegram `_last_update_id` in-memory only** — restart can replay or skip commands. Persist watermark. *(Bugs C-3)*
9. **`init_db` calls `SQLModel.metadata.create_all` on every boot** — causes schema drift between fresh and upgraded installs. Adopt Alembic. *(Perf C-1)*
10. **Template-preview endpoints bypass sandbox timeout** — authenticated user can wedge a worker with `{% for i in range(10**8) %}`. *(Security M-1)*
11. **Telegram webhook handler missing `session.rollback()`** in catch-all — leaves uncommitted writes. *(Backend C-2, [commands/webhook.py:162](packages/server/src/notify_bridge_server/commands/webhook.py#L162))*
12. **CLAUDE.md rule-8 violation**`if (provider.type !== 'immich')` in `RuleEditor.svelte` silently disables people/album picker for other providers. *(Frontend C-2, [RuleEditor.svelte:57](frontend/src/routes/actions/RuleEditor.svelte#L57))*
## Next-tier priorities (HIGH — fix in the same release where practical)
13. Audit `backup_schema.PROVIDER_SECRET_FIELDS` so `webhook_secret`, `password`, `client_secret`, `refresh_token` are scrubbed on export. *(Backend C-5)*
14. Add `asyncio.Lock` around `bridge_self` failure-counter dicts. *(Backend C-4)*
15. Login rate-limit is per-IP only — slow rotated-source brute force succeeds. Add per-account lockout + raise password floor. *(Security M-2)*
16. Three frontend CRUD pages copy cache items into local `$state`, breaking the shared-cache invariant and forcing a full refetch per mutation. *(Frontend H-1/H-2)*
17. Uncancelled `setTimeout` chain in backup restart flow can `window.location.reload()` after navigation. *(Frontend H-5)*
18. Refresh-token race against `logout()` produces spurious "Unauthorized" toasts. *(Frontend H-6/H-7)*
19. Dashboard per-provider GROUP-BY aggregate runs unbounded on every refresh, no caching, no covering index. *(Perf H-1/H-2)*
20. Truncation/parse-mode escaping for Telegram (HTML-aware truncate, `_extract_retry_after` fractional seconds, forum `message_thread_id` routing, 403 "bot blocked" auto-disable). *(Bugs H-various)*
21. Five overlapping glass-card abstractions + radius drift (22/18/14/12 px) + ~71 legacy `rounded-md text-sm bg-…` form inputs that bypass the global Aurora `input{}` rule. *(UI/UX H-CONSIST-01..04)*
22. Hardcoded hex colors (`#059669`, `#ef4444`) in Snackbar/ConfirmModal/actions — bypasses theming. *(UI/UX H-CONSIST-03)*
23. Snackbar has no `aria-live`; nav lacks `aria-current="page"` — invisible to screen readers. *(UI/UX H-NAV-01, A11y)*
24. DST handling in overnight quiet-hours windows. *(Bugs H)*
## What's working well — keep doing this
- **Sandboxed Jinja2 everywhere** (security agent verified every `Environment()` instantiation is `SandboxedEnvironment`).
- **`PinnedResolver` SSRF defence** — handles CGNAT, IPv4-mapped IPv6, DNS rebinding.
- **JWT with `token_version` revocation** — bcrypt offloaded to worker thread, constant-time username probe.
- **Hardened Docker** — non-root, read-only root FS, `cap_drop: ALL`.
- **Aurora/Glass design identity** — distinctive (conic-gradient orb, Newsreader italic display serif, lavender/orchid palette, "signal stream"/"on watch"/"wires"/"pulse" editorial labels). Not generic AI admin work.
- **Frontend type discipline** — `svelte-check` clean, EN/RU exactly 1466 keys each, no `eval`/`innerHTML`/`var`/`==` anywhere.
- **Most SQL hot paths already batched** — `load_link_data` is fully fan-in/fan-out; partial unique indexes on deferred-dispatch are thoughtful.
- **Most v0.8.1 production-readiness items shipped** — fan-out caps, 429 backoff, parse_mode fallback, scheduler misfire grace, Prometheus, deep healthcheck, per-receiver render cache.
## Top missing features worth adding next
Pulled from the bugs-features report — full pitches in [bugs-features-review.md](bugs-features-review.md):
- **Template playground** — "send test against last event" + dry-run with sample payload.
- **Template versioning + rollback** with audit log.
- **Bulk operations** on targets/templates (currently row-by-row).
- **User-side snooze/mute via bot command** ("/mute 2h", "/snooze tonight").
- **Auto-disable receiver on Telegram 403 ("bot blocked")** with admin notification.
- **Rate-limit per target** (separate from global fan-out cap).
- **Weekly digest + per-target stats + per-provider error rate**.
- **Generic webhook provider** and **email / Discord / ntfy.sh / Matrix** channels.
- **Message dedup window** (kills duplicate sends from redelivery and scheduler misfires).
- **First-run "Getting Started" checklist** on empty dashboard (UI/UX).
## How to consume this review
Each report has clickable `file:line` markdown links. Recommended sequence:
1. Read this `README.md`.
2. Skim each report's Executive Summary (top 5-7 bullets).
3. Triage the **Ship blockers (1-12)** above into the next release branch as individual issues.
4. Schedule the **HIGH list (13-24)** for the release after.
5. Treat the feature ideas as a refresh of `.claude/docs/feature-backlog.md`.
+342
View File
@@ -0,0 +1,342 @@
# Backend Production-Readiness Review
Scope: packages/server/src/notify_bridge_server/ and packages/core/src/notify_bridge_core/ (~44k LOC, Python 3.11, FastAPI + SQLModel async + APScheduler + aiohttp).
## Executive Summary
- **Overall quality is high.** The Jinja2 sandbox is consistently applied (every Environment instantiation is SandboxedEnvironment), JWT auth uses bcrypt offloaded to a worker thread, SSRF guard exists with DNS-rebinding mitigation, secrets are masked in logs via a dedicated filter, and most async/SQL patterns show production-aware design (per-tracker sessions, batched IN-queries, partial unique indexes).
- **Top correctness risk: a fire-and-forget asyncio.create_task in ha_subscription._on_status_change** (no reference stored, GC can drop the task) plus thread-unsafe in-memory counters in bridge_self. Both bite on chatty HA installs.
- **Module-level dict caches shared across the event loop have small read-modify-write windows** in services/scheduler.py (adaptive state), services/bridge_self.py (failure counters), commands/handler.py (TTLCache rate limits), and command_sync._dirty_bots. Currently functional under low concurrency; risky under load.
- **Very large hot-path functions** — services/watcher.py:check_tracker (381 lines), services/dispatch_helpers.py:load_link_data (208 lines), the 1880-line database/migrations.py, and the 1365-line services/scheduler.py — concentrate too much logic in one place.
- **Provider-type hardcoding** persists in api/providers.py, services/__init__.py, services/action_runner.py, and services/manual_dispatch.py (if provider.type == immich chains). The watchers _POLL_FACTORIES registry is the right model — extend it.
- **Webhook handlers read the request body BEFORE authenticating** in the Gitea and generic-webhook routes. The Planka route gets it right. Net impact: a peer that knows the URL but not the secret can drive a 1 MiB read per request.
- **autoescape is inconsistent**: True for runtime templates (renderer.py, commands/handler.py), False for preview / sample-context renders in api/template_configs.py, api/slot_helpers.py, and services/notifier.send_test_template_notification. Lower risk (admin-authored input) but mismatch invites surprise.
---
## CRITICAL
### [C-1] _on_status_change schedules an unstored task (GC + drop risk)
File: [packages/server/src/notify_bridge_server/services/ha_subscription.py:240-260](../../packages/server/src/notify_bridge_server/services/ha_subscription.py#L240)
The task created by asyncio.create_task(_record_ha_status(...)) at line 249 is not held anywhere. Python may garbage-collect a task whose only reference is the create_task return value before it completes (Python docs explicitly warn: save a reference to the result). Result: an HA disconnect/reconnect EventLog row silently disappears under memory pressure.
**Fix:** Module-level set[asyncio.Task], add the new task, remove via task.add_done_callback. ha_subscription.start_all already does this correctly (line 315-320); the pattern is already in-house.
### [C-2] Telegram-webhook handler returns 200 OK on uncommitted writes
File: [packages/server/src/notify_bridge_server/commands/webhook.py:130-169](../../packages/server/src/notify_bridge_server/commands/webhook.py#L130)
The catch-all at line 162 swallows handle_command exceptions and returns OK to Telegram. The request already called await session.commit() at line 96 (after save_chat_from_webhook), and any subsequent writes via the dispatcher use NEW sessions inside the command path. If a downstream session inside handle_command partially commits before raising, the dependency get_session does NOT roll back automatically — the context manager only closes.
**Fix:** Either explicitly session.rollback() in the except block, or wrap the per-request mutations in async with session.begin(): so the implicit transaction guarantees rollback on exception.
### [C-3] Gitea/generic webhook reads body BEFORE verifying secret is configured
File: [packages/server/src/notify_bridge_server/api/webhooks.py:167-178](../../packages/server/src/notify_bridge_server/api/webhooks.py#L167) and line 449-454
The sequence is: read 1 MiB raw_body, then check if webhook_secret is empty. A peer that learned the URL but has no secret drives a 1 MiB body read per request. Plankas handler at line 232+ validates the bearer token BEFORE the body read — that is the correct pattern.
**Fix:** Hoist the "if not webhook_secret" (Gitea) and "if auth_mode == none" short-circuit (generic) above _read_bounded_body. Gitea HMAC still needs the body — but bailing on a missing-config-side error first costs nothing.
### [C-4] bridge_self in-memory counters are not async-safe
File: [packages/server/src/notify_bridge_server/services/bridge_self.py:186-230](../../packages/server/src/notify_bridge_server/services/bridge_self.py#L186)
record_poll_failure does _poll_failure_counts[tracker_id] = _poll_failure_counts.get(tracker_id, 0) + 1. These dicts are accessed concurrently from poll loop, HA push, webhook ingest, and dispatcher target-failure recording. Individual dict ops are atomic, but get + 1 + set is not when interleaved with another coroutine that touches the same key. Symptoms: missed threshold crossings, occasional double-emission. Same pattern in _target_failure_counts and _backlog_above_threshold.
**Fix:** Wrap mutating ops in an asyncio.Lock. The reset-and-re-arm semantics already assume serial access — make it explicit.
### [C-5] PROVIDER_SECRET_FIELDS audit needed for backup exports
File: [packages/server/src/notify_bridge_server/api/providers.py:617-625](../../packages/server/src/notify_bridge_server/api/providers.py#L617) and [services/backup_service.py:84-93](../../packages/server/src/notify_bridge_server/services/backup_service.py#L84)
_apply_secrets_provider redacts only fields named in PROVIDER_SECRET_FIELDS. The webhook flow uses a field called webhook_secret (Gitea, Planka, generic) — verify this is in PROVIDER_SECRET_FIELDS (defined in backup_schema.py). A backup export with secrets_mode=INCLUDE that misses webhook_secret leaks a token that grants webhook-forgery rights.
**Action:** Audit PROVIDER_SECRET_FIELDS. Specifically check it includes: api_key, api_token, access_token, webhook_secret, password, client_secret, refresh_token. The _provider_response mask list at api/providers.py:620 is a good cross-reference — both should be the same constant.
---
## HIGH
### [H-1] _compile_template lru_cache competes across tenants
File: [packages/server/src/notify_bridge_server/commands/handler.py:99-103](../../packages/server/src/notify_bridge_server/commands/handler.py#L99)
lru_cache(maxsize=256) keyed by raw template string. Edited templates remain cached. On a multi-tenant install one tenants 256 distinct templates can evict anothers. No invalidation on template-edit.
**Fix:** Drop the cache (Jinja compile is sub-ms) OR add an invalidation call from the template-edit endpoints. The notification renderer (renderer.py:31) uses 512 slots — same problem; consistent fix.
### [H-2] check_tracker is 381 lines with deep coupling
File: [packages/server/src/notify_bridge_server/services/watcher.py:263-644](../../packages/server/src/notify_bridge_server/services/watcher.py#L263)
Loads tracker, polls, writes state, persists EventLog, evaluates gates, defers, dispatches, records bridge_self — all in one function. Refactor candidates: _poll_phase, _persist_state_and_events, _dispatch_phase. This is the watchers hot path; bugs here affect every tracker tick.
### [H-3] load_link_data returns untyped dict[str, Any]
File: [packages/server/src/notify_bridge_server/services/dispatch_helpers.py:539-747](../../packages/server/src/notify_bridge_server/services/dispatch_helpers.py#L539)
Five call sites consume ld["target_type"], ld.get("link_id"), etc. — no static guarantee against key typos.
**Fix:** Introduce a frozen @dataclass class LinkData. Same for per-receiver entries.
### [H-4] N+1 in _resolve_command_context template-slot loop
File: [packages/server/src/notify_bridge_server/commands/handler.py:200-215](../../packages/server/src/notify_bridge_server/commands/handler.py#L200)
One SELECT per distinct command_template_config_id. Already batched for trackers/configs/providers — finish the job. Single WHERE config_id IN (...) query + Python pivot.
### [H-5] N+1 in backup_service.export_backup receiver loop
File: [packages/server/src/notify_bridge_server/services/backup_service.py:187-189](../../packages/server/src/notify_bridge_server/services/backup_service.py#L187)
50 targets = 51 SELECTs. Batch with WHERE target_id IN (...). Audit other sections of this 941-line file for the same pattern (templates -> slots, command configs -> slots).
### [H-6] _dirty_bots mutated from request and scheduler without a lock
File: [packages/server/src/notify_bridge_server/services/command_sync.py:25-95](../../packages/server/src/notify_bridge_server/services/command_sync.py#L25)
mark_bot_dirty runs in request handlers, _flush_dirty_bots on the scheduler executor. Currently safe (snapshot via ready = [...]) but fragile.
**Fix:** Snapshot under lock, or move to a thread-safe primitive.
### [H-7] HA reconnect cycle has no way for CRUD to short-circuit a stale supervisor
File: [packages/server/src/notify_bridge_server/services/ha_subscription.py:163-175](../../packages/server/src/notify_bridge_server/services/ha_subscription.py#L163)
Reload-on-reconnect means a disabled HA provider keeps trying to reconnect at the 30s/300s cadence until next reconnect attempt. CRUD endpoints should call reload_provider (defined at line 339) — verify wiring.
### [H-8] Cached expunged ORM instances are footguns
File: [packages/server/src/notify_bridge_server/services/event_dispatch.py:75-107](../../packages/server/src/notify_bridge_server/services/event_dispatch.py#L75)
_load_trackers_cached returns expunged NotificationTracker rows. Future maintainer calling session.add(tracker) on a stale cached instance triggers DetachedInstance or silent re-INSERT. Document this strongly, ideally convert to a typed projection.
### [H-9] Pending-restore at startup has no timeout
File: [packages/server/src/notify_bridge_server/main.py:142-143](../../packages/server/src/notify_bridge_server/main.py#L142)
apply_pending_restore_if_any runs in lifespan; a partially-corrupt restore could block startup indefinitely. Container liveness probes then fail after grace.
**Fix:** asyncio.wait_for with a generous timeout, or kick off as background task while app starts.
### [H-10] Jinja2 render watchdog uses daemon thread that can pin a CPU forever
File: [packages/core/src/notify_bridge_core/templates/renderer.py:48-73](../../packages/core/src/notify_bridge_core/templates/renderer.py#L48)
Comment acknowledges the trade-off. Multiple concurrent runaway renders can exhaust CPU cores while callers think they timed out. Add a process-level BoundedSemaphore capping concurrent in-flight renders.
### [H-11] _aggregate drops all but the first error
File: [packages/server/src/notify_bridge_server/services/notifier.py:326-335](../../packages/server/src/notify_bridge_server/services/notifier.py#L326)
When all sends fail, only results[0] is returned. Distinct subsequent errors are lost.
**Fix:** Aggregate all errors into a details field.
### [H-12] Generic-webhook header dict materialised twice
File: [packages/server/src/notify_bridge_server/api/webhooks.py:456](../../packages/server/src/notify_bridge_server/api/webhooks.py#L456) and line 475
dict(request.headers) materialises full headers map, then _filter_headers and _redact_sensitive_body walk the payload. With a malicious peer sending many headers (Starlette default 100), bounded but wasteful.
### [H-13] SSRF redirect-walk has no aggregate wall-clock budget
File: [packages/core/src/notify_bridge_core/notifications/telegram/client.py:232-268](../../packages/core/src/notify_bridge_core/notifications/telegram/client.py#L232)
max_redirects = 3, each with 120s _DOWNLOAD_TIMEOUT. Worst case per request: 480s. _TARGET_TIMEOUT_S = 120s in the dispatcher caps the top-level case, but per-asset preloads inside media groups dont all share that cap.
### [H-14] Backlog recovery logic flips latch for in-flight users
File: [packages/server/src/notify_bridge_server/services/bridge_self.py:544-551](../../packages/server/src/notify_bridge_server/services/bridge_self.py#L544)
Recovery loop iterates all known users and flips to False for any not in counts_by_user. If a user transiently has no user_id set on deferred rows (legacy / orphaned), theyre excluded from the GROUP BY and incorrectly marked recovered.
### [H-15] quiet_hours_status silently returns None on start == end
File: [packages/server/src/notify_bridge_server/services/dispatch_helpers.py:110-111](../../packages/server/src/notify_bridge_server/services/dispatch_helpers.py#L110)
The comment notes this is almost always a user mistake. Silent return means the user wonders why their notifications still arrive at all hours. Surface via WARNING log + UI hint.
---
## MEDIUM
### [M-1] register_commands_with_telegram chat overrides loop is sequential
File: [packages/server/src/notify_bridge_server/commands/handler.py:723-776](../../packages/server/src/notify_bridge_server/commands/handler.py#L723)
50 chats with overrides = 50 sequential Telegram round-trips. Use asyncio.gather with a semaphore as in _refresh_telegram_chat_titles.
### [M-2] _run_provider exception backoff has no escalation
File: [packages/server/src/notify_bridge_server/services/ha_subscription.py:278-283](../../packages/server/src/notify_bridge_server/services/ha_subscription.py#L278)
Persistent bug in _emit reconnects every 30s forever. Add exponential backoff with cap and bridge_self alert after N failures.
### [M-3] database/migrations.py is 1880 lines
File: [packages/server/src/notify_bridge_server/database/migrations.py](../../packages/server/src/notify_bridge_server/database/migrations.py)
Past the 800-line guideline. Split per-migration into database/migrations/<name>.py, list in main.py.
### [M-4] Locale-resolution logic duplicated
File: [packages/server/src/notify_bridge_server/services/dispatch_helpers.py:484-491](../../packages/server/src/notify_bridge_server/services/dispatch_helpers.py#L484) and [services/notifier.py:46](../../packages/server/src/notify_bridge_server/services/notifier.py#L46)
Two implementations of locale priority. One source of truth.
### [M-5] _normalize_locale duplicated across modules
File: [packages/server/src/notify_bridge_server/commands/handler.py:632](../../packages/server/src/notify_bridge_server/commands/handler.py#L632)
Five-line copy; move to commands/command_utils.py.
### [M-6] Provider-type if-chain in _test_provider_connection
File: [packages/server/src/notify_bridge_server/api/providers.py:203-250](../../packages/server/src/notify_bridge_server/api/providers.py#L203)
Same chain in services/__init__.py:_make_collection_provider. Both candidates for a single registry.
### [M-7] Secret masking exposes last 4 chars unconditionally
File: [packages/server/src/notify_bridge_server/api/providers.py:624](../../packages/server/src/notify_bridge_server/api/providers.py#L624) and [services/backup_service.py:81](../../packages/server/src/notify_bridge_server/services/backup_service.py#L81)
Fine for 32-char Immich keys. Returns half the value for short secrets. Use plain "***" for len(value) < 16.
### [M-8] Deprecated validate_outbound_url still imported
File: [packages/core/src/notify_bridge_core/providers/immich/client.py:14](../../packages/core/src/notify_bridge_core/providers/immich/client.py#L14)
The sync version uses blocking socket.getaddrinfo on the event loop. Migrate to avalidate_outbound_url.
### [M-9] Lazy cache init has confusing DCL comment
File: [packages/server/src/notify_bridge_server/services/watcher.py:81-113](../../packages/server/src/notify_bridge_server/services/watcher.py#L81)
Comment about Double-check after acquiring lock implies classic DCL — under asyncio, the unlocked first check is safe because theres no thread context switch, but rename to clarify.
### [M-10] Dispatcher concurrency cap is per-dispatch, not process-wide
File: [packages/core/src/notify_bridge_core/notifications/dispatcher.py:58](../../packages/core/src/notify_bridge_core/notifications/dispatcher.py#L58)
_DISPATCH_CONCURRENCY = 16 is INSIDE dispatch(). HA storm = N events x min(M, 16) sends with no outer cap. Add a process-level semaphore in event_dispatch.py.
### [M-11] success=True returned for partial failures
File: [packages/server/src/notify_bridge_server/services/notifier.py:329-335](../../packages/server/src/notify_bridge_server/services/notifier.py#L329)
A test that fails on 1 of 3 receivers returns success=True with a partial_failures count. Introduce a status: "ok"|"partial"|"fail" field.
### [M-12] Telegram command registration not retried on 429
File: [packages/server/src/notify_bridge_server/commands/handler.py:671-693](../../packages/server/src/notify_bridge_server/commands/handler.py#L671)
set_my_commands/delete_my_commands arent retried. Adopt the retry-after handling that _upload_media has.
### [M-13] event_log_id_by_event keyed on id(event)
File: [packages/server/src/notify_bridge_server/services/watcher.py:417-464](../../packages/server/src/notify_bridge_server/services/watcher.py#L417)
CPython object-address as key works because events are held alive in scope, but a typed key would be safer.
### [M-14] Bcrypt-length error wording could be clearer
File: [packages/server/src/notify_bridge_server/auth/routes.py:69-81](../../packages/server/src/notify_bridge_server/auth/routes.py#L69)
User typing 70 ASCII + emoji gets rejected and doesnt understand why. Clarify the byte-count language.
### [M-15] CSP allows unsafe-inline for script-src
File: [packages/server/src/notify_bridge_server/main.py:186-201](../../packages/server/src/notify_bridge_server/main.py#L186)
Acknowledged. SvelteKit --csp build flag emits hashes; switching unblocks dropping unsafe-inline.
### [M-16] Telegram-webhook body size not capped
File: [packages/server/src/notify_bridge_server/commands/webhook.py:71](../../packages/server/src/notify_bridge_server/commands/webhook.py#L71)
update = await request.json() reads with no cap. Add _read_bounded_body pattern.
### [M-17] _log_command_event swallows DB failures invisibly
File: [packages/server/src/notify_bridge_server/commands/handler.py:353-357](../../packages/server/src/notify_bridge_server/commands/handler.py#L353)
Hard DB failure here is invisible. Add a metrics counter.
### [M-18] apply_tracking_display_filters is a 60-line if-branched function
File: [packages/server/src/notify_bridge_server/services/dispatch_helpers.py:350-405](../../packages/server/src/notify_bridge_server/services/dispatch_helpers.py#L350)
Split into _filter_favorites, _apply_order_and_limit, _strip_details_and_tags.
---
## LOW
### [L-1] from .database.models import * in main.py
File: [packages/server/src/notify_bridge_server/main.py:26](../../packages/server/src/notify_bridge_server/main.py#L26)
Comment is honest about purpose, but explicit imports or a single module import is clearer.
### [L-2] None comparisons
All comparisons verified to use is None via grep — no findings.
### [L-3] Magic numbers
Constants are well-named throughout (_TG_429_MAX_ATTEMPTS, _MAX_PENDING_PER_TRACKER, DEBOUNCE_SECONDS, etc.). Only nit: seconds=30 literal in scheduler.schedule_bot_polling could be promoted.
### [L-4] noqa E712 repeated 8+ times for SQLModel boolean comparisons
Switch to .is_(True) for SQLAlchemy idiom, or add E712 to project ruff config.
### [L-5] _check_same_origin is best-effort by design
Acceptable.
### [L-6] _normalize_host strips IPv6 zone IDs silently
File: [packages/core/src/notify_bridge_core/notifications/ssrf.py:105-106](../../packages/core/src/notify_bridge_core/notifications/ssrf.py#L105)
Debug log when stripping changes the host would help diagnose.
### [L-7] _compute_jitter cap of 30s might be tight on hourly polls
File: [packages/server/src/notify_bridge_server/services/scheduler.py:91-105](../../packages/server/src/notify_bridge_server/services/scheduler.py#L91)
Revisit if jitter-collision becomes a real-world issue.
### [L-8] SmtpConfig repr may leak password
File: [packages/server/src/notify_bridge_server/services/notifier.py:205-213](../../packages/server/src/notify_bridge_server/services/notifier.py#L205)
If SmtpConfig is a vanilla dataclass, repr() will leak the password. Verify in notify_bridge_core.notifications.email.client — add field(repr=False) or a custom __repr__.
### [L-9] noqa BLE001 count is high
49 occurrences across 26 files. Each defensible; consider narrowing where possible.
### [L-10] _normalize_for_json does not handle UUID/Decimal
File: [packages/server/src/notify_bridge_server/services/deferred_dispatch.py:124-133](../../packages/server/src/notify_bridge_server/services/deferred_dispatch.py#L124)
No current consumer emits these, but a fallback str() for unknown types would prevent future breakage.
---
## Approval Verdict
**Block** — CRITICAL findings (C-1 unstored task, C-2 missing rollback, C-3 unauthenticated body read, C-4 racy counters, C-5 secret-mask audit) must be fixed before declaring production-ready. Once those are addressed, the HIGH findings can land in a follow-up.
## Quick Wins (low effort, high value)
1. **Wrap every fire-and-forget asyncio.create_task in a module-level set** — search for asyncio.create_task( with no assignment. Definite hit: ha_subscription.py:249.
2. **Move webhook-secret check before _read_bounded_body** in Gitea + generic webhook handlers — 5-line move per endpoint, eliminates pre-auth resource exhaustion.
3. **Add an asyncio.Lock around _poll_failure_counts and _target_failure_counts** mutations — eliminates C-4.
4. **Split migrations.py** — mechanical refactor, ~1 hour, improves blame/review.
5. **Batch the receiver query in backup_service.export_backup** — single IN (...) query, ~10x faster.
6. **Replace from .database.models import \*** with explicit imports — small clarity win.
+714
View File
@@ -0,0 +1,714 @@
# Bugs + Missing Features — Production-Readiness Review
Repo: `c:\Users\Alexei\Documents\service-to-notification-bridge` (v0.8.1 baseline)
Date: 2026-05-22
Scope: full repo (backend Python/FastAPI, Svelte 5 frontend, providers + dispatchers + bot commands)
---
## Executive summary
- **The code is in much better shape than typical pre-1.0 code.** Quiet-hours,
SSRF, JWT, secret redaction, rate-limit fan-out caps, partition-by-media-kind,
parse_mode retry, scheduler misfire-grace, Prometheus metrics, deep
healthcheck, and per-receiver render cache are all already implemented and
well-tested.
- **The single biggest shipping risk is webhook idempotency.** Gitea, Planka,
and the generic webhook endpoint all dispatch on every POST regardless of
redelivery — there is no `X-Gitea-Delivery` / `X-Hub-Delivery` dedup table.
An upstream retry storm sends the same notification N times.
- **The deferred-dispatch drain has a duplicate-send window** if the process
dies between `dispatcher.dispatch()` returning and `session.commit()`
the row stays `pending` and the periodic catch-up scan re-drains it.
- **Telegram update offset (`_last_update_id`) is in-memory only** — on
restart, the bot replays already-handled updates or skips ones Telegram
has discarded. Combined with no per-update idempotency, this is a
duplicate-command surface.
- **Several Telegram features are silently unsupported**: forum threads
(`message_thread_id`), bot-blocked-by-user detection (403 → keep retrying
forever), and inline-button callback queries. None blocks shipping today
but each is a near-term ask from any real user.
- **No template versioning / dry-run / playground** — every template edit is
immediately live. There is no way to validate a new template against a
sample payload before flipping the switch, and no rollback path.
- **Frontend lacks bulk operations and import/export of templates+targets.**
An operator with 30 trackers cannot bulk-toggle, bulk-edit, or move a
template across users.
---
## Part A — Bugs and reliability issues
Severity legend: **CRITICAL** = data loss / duplicate user-visible messages /
silent stop-shipping; **HIGH** = wrong behavior under realistic conditions;
**MEDIUM** = degrades UX or operability; **LOW** = polish.
### CRITICAL
#### A1. Webhook redelivery causes duplicate notifications (no idempotency)
**Location**: `packages/server/src/notify_bridge_server/api/webhooks.py:156`
(`gitea_webhook`), `:225` (`planka_webhook`), `:427` (`generic_webhook`).
**Scenario**: Gitea retries a webhook after 30s if the bridge returns 5xx,
times out under load, or if the operator clicks "Test Delivery" twice. Every
retry produces a fresh notification because the handlers never check
`X-Gitea-Delivery` (Gitea's per-delivery UUID), nor do they record any
event_id/hash for `parse_generic_webhook` events.
**Fix**: Add a `webhook_delivery` table with `(provider_id, delivery_id)`
unique constraint and `created_at`. Insert before dispatch (`INSERT OR IGNORE`
on SQLite, `ON CONFLICT DO NOTHING` on Postgres); if the insert is a no-op,
return `{"ok": true, "skipped": "duplicate"}`. For Gitea use the
`X-Gitea-Delivery` header; for Planka use a hash of `event_type +
payload.id + payload.createdAt`; for generic webhooks use a configurable
JSONPath expression to derive an idempotency key, falling back to a SHA256 of
the raw body. TTL prune older than 7 days.
#### A2. Deferred-dispatch drain can double-send on process crash
**Location**: `packages/server/src/notify_bridge_server/services/deferred_dispatch.py:721-758`.
**Scenario**: Inside `_process_row`, `dispatcher.dispatch()` actually
delivers the Telegram message (HTTP 200 returned, user phone buzzes).
The function then sets `row.status = "fired"` (line 734) but the surrounding
`session.commit()` (line 577) hasn't run yet. Process is killed (OOM,
SIGTERM during deploy, host reboot). On restart, `_run_deferred_drain_catchup`
re-fetches the still-`pending` row and dispatches it again — **the user gets
the same album twice**.
**Fix**: Either (a) record an outbound dedup key per-row before dispatch
(`row.dispatch_id = uuid4(); session.commit()` first), then ask the channel
client to send-or-no-op based on that ID; or (b) flip the row to a
`"in_flight"` state with a short timeout in a pre-dispatch transaction so a
restart sees it as poisoned and aborts. Option (a) is more correct but
needs per-channel cooperation; option (b) is the cheap fix.
#### A3. Telegram update offset is in-memory only — restart replays or loses commands
**Location**: `packages/server/src/notify_bridge_server/services/telegram_poller.py:31`
(`_last_update_id: dict[int, int] = {}`).
**Scenario**: A user types `/random Family`. Telegram delivers update_id=4711.
The bridge processes the command, sends back the media, and crashes before
APScheduler ticks again. On restart, `_last_update_id` is empty, so we call
`getUpdates(offset=None)` → Telegram returns 4711 again → we send the user
the same album a second time. Conversely, if Telegram's 24-hour retention
expired during a long outage, we silently skip pending updates.
**Fix**: Persist last_update_id in DB (`telegram_bot.last_update_id` column).
Combine with A2-style command idempotency by inserting
`(bot_id, update_id)` into a dedup table before processing.
### HIGH
#### A4. Telegram "bot blocked by user" / "chat not found" never short-circuits
**Location**: `packages/core/src/notify_bridge_core/notifications/telegram/client.py`
(`send_message`, `_upload_media`, etc.). Errors with
`error_code == 403` (Forbidden, "Bot was blocked by the user") and 400
"chat not found" / "user is deactivated" are returned as failures but
never recorded so the receiver gets removed/disabled.
**Scenario**: A user blocks the bot. Every scheduled "Good morning memory"
fires a sendMessage that Telegram instantly 403s. Bridge logs an error,
moves on, repeats forever. The bridge_self target-failure counter eventually
fires but the underlying receiver is never disabled. With many such chats
the operator has no easy cleanup path.
**Fix**: In the dispatcher, on `error_code in (403, 400 with description
matching "chat not found"/"user is deactivated")`, automatically set
`TelegramChat.commands_enabled = False` and either flag the receiver as
`disabled` with reason `blocked_by_user` or surface it via a new
`/admin/blocked-chats` view. Also stop further retries that round.
#### A5. Telegram forum-thread (topic) routing not supported
**Location**: telegram client never accepts/sends `message_thread_id`.
**Scenario**: Operator points the bridge at a group's "Releases" forum
topic. Today every message lands in the General topic instead — there is
no way to specify the topic. This is a hard requirement for any non-trivial
group install. Currently `reply_parameters` is the only thread-adjacent
field used; `message_thread_id` is silently absent.
**Fix**: Add an optional `message_thread_id` per-receiver (or per-target)
config, pass through `send_message`, `_upload_media`, and `_post_media_group`.
Auto-extract from incoming command updates' `message.message_thread_id` so
the bot can reply into the same topic.
#### A6. `bot.token` read after commit without refresh in webhook flow
**Location**: `packages/server/src/notify_bridge_server/commands/webhook.py:92-97`.
**Scenario**: The comment acknowledges "AsyncSession expires instances on
commit" and snapshots `bot_id`/`bot_token` before commit, but `await
session.refresh(bot)` is also called after the commit. If `session.refresh`
fails (e.g. row was deleted by an admin concurrently — bot rotation), the
exception is caught as a warning and the rest of the handler still runs
using the stale local `bot_id`/`bot_token`. The window is small but real.
**Fix**: Remove the `session.refresh(bot)` since the snapshot already
covers everything the handler needs. The refresh adds risk for no gain.
#### A7. Deferred-dispatch coalescing has a JSON-mutation bug under concurrent defers
**Location**: `packages/server/src/notify_bridge_server/services/deferred_dispatch.py:307`
(`_find_pending_asset_rows`).
**Scenario**: Two near-simultaneous `assets_added` events for the same
`(link_id, collection_id)` from two upstream pollers (HA chat-bus +
periodic Immich). Both call `defer_event` concurrently. The two transactions
both see "no pending row", both `session.add(new_row)`, and SQLite cheerfully
inserts two rows. The drain then fires both, sending the same combined media
twice. Note that the partial UNIQUE index from v0.8.1 protects only the
`bridge_self` provider row, not the deferred queue.
**Fix**: Add a partial UNIQUE index `UNIQUE(link_id, collection_id, event_type)
WHERE status = 'pending'` on `deferred_dispatch`, then convert `defer_event`
to `INSERT ... ON CONFLICT (link_id, collection_id, event_type) DO UPDATE`
and merge `event_payload` inside the SQL or in a re-read+retry loop.
#### A8. Quiet-hours overnight window + DST transition can produce wrong fire_at
**Location**: `packages/server/src/notify_bridge_server/services/dispatch_helpers.py:121-128`.
**Scenario**: User in `Europe/Minsk` (UTC+3, no DST anymore) sets quiet
hours 22:00-06:00. For a user in a DST-observing zone (e.g.
`America/New_York`), on the "spring forward" night where 2:00 → 3:00, an
event arriving at 02:30 local time gets `end_today = now_local.replace(hour=6,
minute=0)`. But `.replace()` ignores DST adjustments — the resulting
`datetime` may sit in the skipped hour or have ambiguous DST status. Two
hours later, the dispatcher sees the quiet window as "still active" or "30
min ago" depending on the system.
**Fix**: After `.replace(hour=t_end.hour, minute=t_end.minute, ...)`, pass
through `tz.localize` (zoneinfo's behavior: re-walk via `astimezone`) and
explicitly handle the `fold=` parameter. Add tests using
`zoneinfo.ZoneInfo("America/New_York")` and known DST transition dates.
#### A9. Quiet-hours `start == end` returns None — silently no quiet hours
**Location**: `packages/server/src/notify_bridge_server/services/dispatch_helpers.py:110-111`.
**Scenario**: User UI submits `quiet_hours_start = "00:00"` and
`quiet_hours_end = "00:00"`, thinking "all day quiet". The function returns
`None` (no quiet window) — the user gets pinged at 3am even though the UI
says "quiet hours enabled". Same code path eats malformed times silently.
**Fix**: Bubble up `ValueError`/`malformed input` to the API validator on
write so the user gets a 422 with a specific error message rather than
silently broken behavior. Define `00:00-00:00` as "always quiet" or reject
it explicitly with a clear error.
#### A10. Telegram `_truncate` cuts mid-HTML-tag → parse_mode fallback then loses formatting
**Location**: `packages/core/src/notify_bridge_core/notifications/telegram/client.py:144-149`
(`_truncate`).
**Scenario**: A template renders to 4090 chars and an
`<a href="https://...">...</a>` straddles the 4096-byte boundary. The
truncate function takes a flat string slice, so the final character may be
inside a tag → Telegram returns 400 "can't parse entities" → the retry
strips parse_mode → the user sees `<a href="...">` literally in their chat.
**Fix**: Make `_truncate` HTML-aware: scan from the right and abandon
truncation at the start of any tag boundary, OR strip incomplete tags after
truncating. A simpler intermediate fix: pop any unclosed `<a>` /`<b>`/`<i>`
detected by a regex over the truncated string.
#### A11. JSON-payload depth/size hardened in backup, not in webhooks
**Location**: `packages/server/src/notify_bridge_server/api/webhooks.py:43-71`
(`_read_bounded_body` only caps total bytes).
**Scenario**: Generic webhook accepts a 999KB payload (under the 1MB cap)
but with 50 levels of nesting. `json.loads` succeeds, then
`parse_generic_webhook` evaluates JSONPath expressions in a loop and the CPU
spends seconds chasing pointers. Multiple concurrent malicious requests can
peg the event loop.
**Fix**: Reuse the depth/node guards from
`packages/server/src/notify_bridge_server/services/backup_service.py`
(JSON depth cap 10, node count cap 100k). Either share the helper or
re-implement around `json.loads(object_pairs_hook=...)`.
#### A12. Generic-webhook `auth_mode="none"` with `acknowledge_unauthenticated` is per-provider, not per-user
**Location**: `packages/server/src/notify_bridge_server/api/webhooks.py:294-323`.
**Scenario**: v0.8.1 added the `acknowledge_unauthenticated=true` opt-in,
but it's only stored in `provider.config` JSON. A multi-user install where
one user accepts unauthenticated and another doesn't would suffice. But
because anyone with the webhook URL can also infer the token (URLs are not
secret in real deployments — they end up in upstream config files, logs,
build artifacts), `auth_mode="none"` is dangerous beyond "explicit opt-in":
an attacker who guesses the path can DoS the rate limiter by burning the
60/min budget.
**Fix**: Refuse to even create a `webhook` provider with `auth_mode="none"`
in production unless a separate environment guard
`NOTIFY_BRIDGE_ALLOW_UNAUTHENTICATED_WEBHOOKS` is set; AND drop the rate
limit to 10/min for `auth_mode="none"` providers.
#### A13. `_extract_retry_after` returns int but Telegram `retry_after` is fractional
**Location**: `packages/core/src/notify_bridge_core/notifications/telegram/client.py:59-78`.
**Scenario**: Modern Telegram sometimes returns `retry_after` as a float
(e.g. `1.5`). The current code does `int(group(1))` and `isinstance(ra,
(int, float))`. Regex `\d+` only matches integers. So a `1.5s` retry-after
becomes "no retry-after found" → fallback 1s sleep → retry too early → second
429 → eventually the bounded retry budget runs out.
**Fix**: Loosen the regex to `\d+(?:\.\d+)?` and `float(m.group(1))`,
preserve fractional via `await asyncio.sleep(retry_after + 1)` with float.
#### A14. APScheduler date-job collision when two windows end at the exact same second
**Location**: `packages/server/src/notify_bridge_server/services/scheduler.py:1127-1132`
(`_drain_job_id_for`). The job id is keyed on `YYYYMMDDHHMMSS`. Comment in
code acknowledges "two trackers... seconds different ... would collide", but
two windows ending at the exact same second still collide on a single job id
`replace_existing=True` silently drops the second.
**Scenario**: 30 users with quiet_hours_end=`07:00`. All 30 windows end at
the same wall-clock second. Only one drain job is scheduled. That single
job fires `drain_deferred_due()` which scans all rows globally so all 30
get drained — actually fine. **But** if the global drain function ever
filters by user/tracker (a likely near-term change for multi-tenant), the
collision becomes silent data loss.
**Fix**: Either keep the global drain (and document the assumption) or
add a tracker_id segment to the job_id and let APScheduler dedup naturally.
#### A15. `_handle_webhook_conflict` reclaim races against a parallel admin action
**Location**: `packages/server/src/notify_bridge_server/services/telegram_poller.py:163-218`.
**Scenario**: Admin clicks "Switch to webhook mode" in the UI, which sets
`update_mode=webhook` and calls `set_webhook(...)`. Concurrently, the next
poll tick for the same bot hits the conflict, calls `delete_webhook` → the
admin's webhook is wiped 1s after they set it. The poll tick checks
`bot.update_mode != "polling"` *before* the conflict reclaim, but the
reload is best-effort and the conflict reclaim path runs unconditionally
once entered.
**Fix**: Re-check `bot.update_mode == "polling"` inside
`_handle_webhook_conflict` before calling `delete_webhook`; or take an
advisory lock on the bot row for the duration of the mode flip.
#### A16. Discord 2000-char split breaks on Unicode codepoint boundaries
**Location**: `packages/core/src/notify_bridge_core/notifications/discord/client.py:60-80`
(`_split_message`).
**Scenario**: A template renders to 2050 chars with emoji at position
1998-1999 (each emoji is 2 surrogates / multi-byte UTF-8). The split uses
`text.rfind("\n", 0, limit)` and falls back to character index `limit`,
which is a Python str index → that part is OK in CPython 3, but if the
content contains a grapheme cluster (emoji + zero-width-joiner + skin tone),
slicing at `limit` mid-cluster renders as the broken emoji "□" in Discord.
**Fix**: Use a grapheme-cluster boundary library (e.g. `regex` module with
`\X`) or at minimum back off to the previous whitespace if `limit` is
inside a likely cluster.
### MEDIUM
#### A17. Per-target failure counter does not distinguish receivers within a target
**Location**: `packages/server/src/notify_bridge_server/services/event_dispatch.py:311-333`.
**Scenario**: A target has 10 receivers. 1 chat is blocked, 9 work. Today
`maybe_emit_target_failure` is called for the target — but the success
counter (`record_target_success`) is also called for the same target on the
other 9. Net counter behavior depends on call order. With the
default-threshold 5, this oscillates.
**Fix**: Track success/failure per receiver, not per target; or only call
`maybe_emit_target_failure` when `all` receivers failed for the target.
#### A18. `_cleanup_old_events` does not delete cancelled `DeferredDispatch` rows
**Location**: `packages/server/src/notify_bridge_server/services/scheduler.py:332-364`.
**Scenario**: The daily cleanup deletes `EventLog`, `WebhookPayloadLog`,
`ActionExecution`. Cancelled / fired / dropped `DeferredDispatch` rows live
forever in the DB. Active install with chatty providers accumulates millions
of rows; eventually the `_load_pending_drain_jobs` query, `_trim_queue_if_needed`,
and the catch-up scan all degrade.
**Fix**: Add `delete(DeferredDispatch).where(status.in_(["fired", "dropped",
"cancelled"]), fired_at < cutoff)` to the cleanup.
#### A19. `random.shuffle(shuffled)` in `_sort_assets` uses non-deterministic seed
**Location**: `packages/server/src/notify_bridge_server/services/dispatch_helpers.py:317-320`.
**Scenario**: Two identical events arriving in close succession (deferred-
dispatch merge, then drain re-renders) shuffle into different orders. With
the deferred-dispatch coalescing logic, this produces a visual "they're not
the same album" surprise in the chat history.
**Fix**: Seed `random` with a stable per-event hash
(`hash(event.event_type.value + event.collection_id + event.timestamp.isoformat())`).
#### A20. `_poll_tracker` swallows exception, drops it at `_LOGGER.error` not `exception`
**Location**: `packages/server/src/notify_bridge_server/services/scheduler.py:657-666`.
**Scenario**: An exception in `check_tracker` is logged as `_LOGGER.error("Error
polling tracker %d: %s", tracker_id, e)` — no traceback. Production debugging
of "why is tracker 42 silently broken since yesterday" requires the stack.
**Fix**: Change to `_LOGGER.exception("Error polling tracker %d", tracker_id)`.
#### A21. Long bot commands → `/help` reply > 4096 chars truncates without warning
**Location**: `packages/server/src/notify_bridge_server/commands/handler.py:521-532`,
combined with `send_reply``send_telegram_message``_truncate` to 4096.
**Scenario**: A user with 20 enabled commands runs `/help`. Each command +
description (RU) crosses 250 chars → 5000 chars total → truncated mid-command.
The user sees a half-list that suggests we forgot half the commands.
**Fix**: Split `/help` over multiple messages by command category (provider).
#### A22. `parse_command` truncates to 512 chars — long search queries lost
**Location**: `packages/server/src/notify_bridge_server/commands/parser.py:15`.
**Scenario**: `/search a very long query containing emoji 🎉 and more text that
the user really meant to send because they pasted a long string from somewhere…`
gets clipped to 512 chars silently. The trailing count parser then operates
on the truncated text, possibly extracting a count from mid-query.
**Fix**: Either reject `>512` with `parse_command` returning a sentinel
"too_long" tuple, or just stop truncating — the Telegram limit is already
4096 and we already truncate the response side.
#### A23. Periodic catch-up scan can dispatch a stale event payload
**Location**: `packages/server/src/notify_bridge_server/services/deferred_dispatch.py:628`
(`_process_row`).
**Scenario**: An `assets_added` event is deferred at 22:00. At 06:00 the
quiet window ends, drain re-fetches `link_data`. The assets in `event_payload`
include URLs and asset metadata. But the user has since deleted those photos
from Immich. The dispatcher tries to download → 404. Notification shows
"5 photos added to Album X" but the actual media fails to attach.
**Fix**: For `assets_added`, re-validate asset existence against the
provider before dispatch (one batched `getAssets` call). Drop missing IDs
from the event, mark with "delivered_after_quiet_hours" + extra hint
`"missing_count": N` in details. For deferred windows >12h this is the
right behavior; for shorter windows the lookup is wasted work, so gate on
`(now - deferred_at).hours >= 6`.
#### A24. Watcher / scheduler restart can lose adaptive polling state
**Location**: `packages/server/src/notify_bridge_server/services/scheduler.py:67-88`
(`_adaptive_state: dict`).
**Scenario**: Module-level dict resets on restart. A tracker that had ramped
up to 1-in-4 ticks goes back to every-tick polling. Over a fleet of 50
trackers in steady-state idle, this triggers a thundering herd of every-tick
polls right after deploy. Combined with no DB-level rate limiting on the
upstream Immich/Gitea API, it can rate-limit the operator out of their own
services for ~5min.
**Fix**: Either persist the adaptive state in `notification_tracker_state`
(cheap on shutdown via `atexit`) or stagger the initial ticks via
APScheduler's `next_run_time` instead of relying on the existing jitter.
#### A25. `defer_event` `return "cancelled"` logic is incorrect in some merge paths
**Location**: `packages/server/src/notify_bridge_server/services/deferred_dispatch.py:444`.
**Scenario**: The `cancelled` return branch checks `upd_added is None or
upd_added.status == "cancelled"` AND same for `upd_removed`. But if both
`upd_added` and `upd_removed` are `None` (i.e. there were no pending rows
to begin with), `fully_cancelled` is `False` → returns "merged". That's
fine. But the more subtle issue: an "insert" action with one of the rows
being cancelled returns "merged" — should be "inserted". The dashboard
"merged" status confuses the operator looking at why no defer row exists.
**Fix**: Rewrite as a clearer state machine: distinguish "inserted",
"merged_into_existing", "fully_cancelled".
#### A26. `_fetch_bytes` and `_safe_get` honor only 3 redirects with no Retry-After awareness
**Location**: `packages/core/src/notify_bridge_core/notifications/telegram/client.py:217-268`.
**Scenario**: Immich behind a CDN can chain `302 → 302 → 200`. With 4 hops
it falls through to "Too many redirects". A user complains "old photos
suddenly missing in notifications".
**Fix**: Bump to 5 redirects and surface the chain in the error string for
easier debugging.
#### A27. No structured event log filter UI for "show me all drops in the last hour"
**Location**: `packages/server/src/notify_bridge_server/api/status.py`
`event_log` rows have `details.dispatch_status` field but no API filter
exposes it. The frontend can fetch only via global filter on `event_type`.
**Scenario**: An operator sees "messages are missing today". They want to
filter event_log to `dispatch_status in (dropped_quiet_hours_nondeferrable,
deferred_then_dropped, deferred_then_failed)`. Today they can't.
**Fix**: Add `dispatch_status` and `dispatched=true|false` as first-class
event_log columns (denormalized from `details`), plus API + UI filter.
#### A28. `_render_cmd_template` falls back to `"[No template: X]"` user-visible text
**Location**: `packages/server/src/notify_bridge_server/commands/handler.py:111-115`.
**Scenario**: An operator removes a template slot by mistake. The next user
who runs `/random` sees `[No template: response_random]` in chat. Not just
ugly — it leaks internal slot names.
**Fix**: Show a friendly "Sorry, something went wrong on our side" + log at
error level. Better: refuse to disable the slot if it's referenced.
### LOW
#### A29. `_truncate`'s ellipsis can land inside a multi-byte char
The marker `"…"` is one Unicode codepoint (3 bytes UTF-8) but the truncate
counts characters, not bytes. Telegram counts UTF-16 code units, so for a
4090-char message ending in emoji, the calculation is off by a small constant.
Won't break sends but messages may end up slightly longer than `TELEGRAM_MAX_TEXT_LENGTH`
allows. Re-measure in UTF-16 code units (`len(s.encode('utf-16-le')) // 2`).
#### A30. `NotificationDispatcher._render_cache` set to fresh dict on every dispatch — comment says "reuse"
The instance attribute `self._render_cache` is reset to `{}` at the start
of every `_send_to_target` (line 245). The cache only helps across receivers
within one target, not across targets. The comment at line 111-115 implies
broader reuse. Either align comment with reality or actually share across
targets within one `dispatch()` call.
#### A31. Frontend `entity-cache.svelte.ts` doesn't propagate stale-cache errors
The shared `$state`-based caches return stale data silently if the underlying
fetch fails after a successful initial load. A user sees old target list
during an outage and is confused why edits aren't sticking.
---
## Part B — Missing functionality and "cool feature" gaps
Tier legend: **must-have** = blocks prod for any non-trivial install;
**nice-to-have** = clear value, ship in next minor; **aspirational** = ship
when v1.0+ slows down.
Effort: **S** ≈ 1-2 days; **M** ≈ 1 week; **L** ≈ 2+ weeks.
### Already in the backlog (post-v0.8.1 status check)
#### B1. Target-level quiet hours (per-target DND, multi-window, days-of-week, silent mode)
**Status**: Still missing in v0.8.1. The backlog item proposed a v1 cut
(target-level windows + `silent` mode for Telegram = `disable_notification=True`).
None of the proposed code paths exist:
- `notification_target.quiet_hours_json` column — not present.
- `disable_notification=True` plumbing through `TelegramClient.send_message`
— not present.
- Days-of-week filter — not present.
**Pitch**: Quiet hours bind to the *watcher* (tracking config); users want
DND at the *destination*. "Don't ping my phone at night, regardless of
which provider".
**Who benefits**: Every user. Today they have to recreate per-link windows.
**Effort**: **M** (1 week — backend dispatcher gate + frontend Aurora-style fieldset).
**Tier**: **must-have for prod**.
#### B2. Immich Smart Actions expansion (auto-favorite by person, auto-archive, share-link rotation)
**Status**: Auto-Organize exists; no other action descriptors are shipped.
**Pitch**: Reuse the existing action descriptor pipeline. Auto-favorite-by-person
is the smallest cut.
**Effort**: **M** per action (a few days each).
**Tier**: nice-to-have.
#### B3. Block-based template builder
**Status**: Not started. `JinjaEditor` is unchanged.
**Effort**: **L** — frontend-only but big.
**Tier**: aspirational.
### Newly identified — must-have for prod
#### B4. Webhook delivery dedup table + "Test Delivery" replay
**Pitch**: Add the dedup table from A1, plus a `/api/webhooks/{provider_id}/replay/{delivery_id}`
endpoint that admin can hit to re-dispatch a stored payload without the upstream
provider needing to resend. Combined with the existing `WebhookPayloadLog`,
this is "click to retest" in the UI.
**Who benefits**: Every webhook provider. Replay is invaluable for debugging
template edits.
**Effort**: **M**.
**Tier**: **must-have for prod**.
#### B5. "Send test message" / template playground
**Pitch**: From the template editor, click "Try this template against the
last received event" → render preview, optionally send to a sandbox chat.
Bypass dispatch but exercise the full Jinja pipeline.
**Who benefits**: Every template edit today is a leap of faith — the operator
modifies the template, waits for the next real event, hopes nothing breaks.
**Effort**: **S-M**. The preview infrastructure already exists
(`services/sample_context.py`); add a "send to chat X" button.
**Tier**: **must-have for prod**.
#### B6. Template versioning + rollback
**Pitch**: Auto-snapshot each template on save (last 10 revisions). UI shows
diff between version N and N-1, "Restore" button. Same for command templates.
**Who benefits**: An operator who tweaks a template at midnight and goofs
the syntax needs an undo button.
**Effort**: **M**. New `template_revision` table; new endpoints; UI button.
**Tier**: **must-have for prod**.
#### B7. Bulk operations on trackers / targets / links
**Pitch**: Multi-select in lists → "disable selected", "delete selected",
"export selected templates as JSON bundle", "move to user X".
**Who benefits**: Operators with >10 trackers. A common pain point: deploying
the bridge for a new family member requires N clicks per tracker.
**Effort**: **M** (frontend-heavy).
**Tier**: **must-have for prod**.
#### B8. Bot blocked / chat-not-found auto-disable + dashboard
**Pitch**: Detect Telegram 403 / 400 chat-related errors. Mark the receiver
or `TelegramChat` as `disabled_by_remote`. Surface in a "Stale receivers"
admin view with a "Try resending invite" / "Delete chat" button.
**Who benefits**: Every Telegram user. Today the bridge silently sprays
errors until a human looks.
**Effort**: **S**.
**Tier**: **must-have for prod**.
#### B9. Forum-thread (topic) routing for Telegram
**Pitch**: Per-receiver `message_thread_id` field, auto-detected from incoming
command messages. UI: when adding a chat that's a forum, show a topic
selector populated via `getForumTopicIconStickers` + `getChat`'s `is_forum`.
**Who benefits**: Any group install where the user wants notifications in a
dedicated topic.
**Effort**: **M**.
**Tier**: **must-have for prod**.
#### B10. Telegram inline buttons + callback queries
**Pitch**: Templates can declare `{% buttons %}` with action descriptors.
Bridge listens for `callback_query` updates, dispatches to a registered
action (e.g. "Mark album as favorite", "Snooze this tracker for 1h", "Run
HA service light.turn_off").
**Who benefits**: Power users. Foundation for several other features
(Immich duplicate-cluster review, HA action button → service call, snooze).
**Effort**: **L**.
**Tier**: nice-to-have but unlocks the next 3 items.
#### B11. User snooze / mute via bot command
**Pitch**: `/snooze 1h` mutes the bot's outbound chat for 1h.
`/mute provider gitea` mutes a whole provider for that chat. `/wake` undoes.
Implemented as a per-receiver `snoozed_until` column.
**Effort**: **S-M**.
**Tier**: **must-have for prod** (user-side relief valve).
### Newly identified — nice-to-have
#### B12. Per-target / per-user rate limit (send-side)
**Pitch**: Cap outbound messages per minute per receiver. Existing 429
backoff handles Telegram's limit, but a runaway template / event-storm
provider can still spray the user's phone with 200 messages.
**Effort**: **S**. Token bucket per chat_id in `_send_telegram`.
**Tier**: nice-to-have.
#### B13. Message dedup window (idempotency key per outbound message)
**Pitch**: SHA256 of `(target_id, receiver_id, rendered_message,
event_collection_id)`. If the same key was sent in the last 5min, skip.
**Effort**: **S**.
**Tier**: nice-to-have (lots of overlap with A1+A2 but addresses the
end-of-pipeline dedup, after all coalescing).
#### B14. Weekly digest / per-target stats / per-provider error rate
**Pitch**: Cron-based weekly summary email/Telegram. "Top 5 noisy trackers",
"Receivers with >X% failure rate", "Top 5 days of the week with the most
activity". Operator preventive maintenance.
**Effort**: **M**.
**Tier**: nice-to-have.
#### B15. Mobile-friendly minimal mode for the SPA
**Pitch**: The Aurora redesign is a lot for mobile. A "manage from phone"
minimal layout — list of trackers, click to toggle, click to mute. Stops
operators from needing a desktop to silence a chatty tracker at 1am.
**Effort**: **M**.
**Tier**: nice-to-have.
#### B16. Audit log of admin actions
**Pitch**: New `audit_log` table. Every create/update/delete on
`NotificationTracker`, `NotificationTarget`, `TemplateConfig`, `ServiceProvider`,
`TelegramBot`, `User`, etc. writes a row with `(user_id, action,
entity_type, entity_id, before_json, after_json, ip, ua)`. Admin UI tab.
**Effort**: **M**. SQLAlchemy event listeners on the affected models.
**Tier**: nice-to-have for multi-admin installs; must-have if any
compliance requirement.
#### B17. Health → not just /ready, but per-component status page
**Pitch**: `/api/health/components` returns `{providers: [{id, last_ok_at,
last_error}], targets: [{id, last_ok_at, last_error}], scheduler:
{job_count, next_fires}}`. Frontend "Status" tab.
**Effort**: **S-M**. The data is already in `EventLog` / scheduler API.
**Tier**: nice-to-have.
#### B18. Provider unreachable backoff + escalation
**Pitch**: Today `bridge_self` emits `bridge_self_poll_failures` after N
consecutive fails. Add (a) exponential backoff on the polling interval after
M failures so we don't hammer a down host, and (b) recovery notification
when the provider comes back.
**Effort**: **S**.
**Tier**: nice-to-have.
#### B19. RSS provider
**Pitch**: Generic RSS/Atom feed poller. One more provider, reuses event_dispatch.
Long-tail value (operator wants "notify me when a blog publishes").
**Effort**: **M**.
**Tier**: nice-to-have.
#### B20. Mobile push / FCM channel
**Pitch**: A dedicated FCM "Receiver" type so the user can ship their own
companion app. Today Telegram is the only realtime channel; email is too
slow; webhook out is for plumbing.
**Effort**: **L**.
**Tier**: aspirational.
### Newly identified — aspirational
#### B21. Conversation threading per source (one notification thread per album / repo)
**Pitch**: Use Telegram `reply_parameters` to chain all notifications about
"Album X" as a single thread that grows over time. Today every notification
is a top-level message. Threading turns the chat into a navigable history.
**Effort**: **M**. Store `last_message_id` per `(target_id, collection_id)`,
pass as `reply_to_message_id`.
**Tier**: aspirational but a clear differentiator.
#### B22. A/B test variants for templates
**Pitch**: A template config can carry 2 variants. The dispatcher
hash-routes receivers to A or B; the dashboard shows "variant A's response
time / click rate / receiver mute rate".
**Effort**: **L**.
**Tier**: aspirational.
#### B23. Dark-launch a new template before enabling it
**Pitch**: "Send-to-sandbox-chat-only" toggle on a template config. The new
template renders against real events but only goes to one operator's chat
for 1 week. Then promote to production.
**Effort**: **M**. Builds on template versioning (B6).
**Tier**: aspirational.
#### B24. Scheduled template changes
**Pitch**: "On 2026-12-25 at 09:00, switch template_config X to draft Y".
Useful for holiday-themed greetings or batch migrations.
**Effort**: **M**.
**Tier**: aspirational.
#### B25. HA service-call from a Telegram inline button
**Pitch**: Building on B10. A template renders `{% button hass:light.turn_off
target=living_room %}`. User clicks → bridge calls HA `light.turn_off`.
**Effort**: **M** (after B10).
**Tier**: aspirational.
---
## Ship-blocker checklist (do not widen user audience without)
Order is rough priority (top first). Most are also called out in Part A.
1. **A1** — Webhook idempotency table (Gitea/Planka/generic). Without this,
one upstream retry storm can double-/quadruple-spray every user.
2. **A2** — Deferred-dispatch crash window. A redeploy mid-drain duplicates
every queued notification. Implement either the `dispatch_id`
pre-commit OR the `in_flight` state machine.
3. **A3** — Persist Telegram update offset. Same root cause class as A1/A2;
matters less if A1+A2 are fixed but should land together.
4. **A4 / B8** — Bot blocked / chat-not-found auto-disable. A user blocking
the bot must not generate infinite errors.
5. **A11** — Webhook JSON depth/node cap (mirror the backup guard).
6. **A9** — Quiet-hours `start == end` confirmation; either accept "always
quiet" semantics or reject in the API validator.
7. **A8** — DST handling in quiet-hours overnight window. Verify with
tests that include known transition timestamps.
8. **B5** — "Send test message" / template playground. Without this, every
template edit is a flying blind change against a live system.
9. **B6** — Template versioning + rollback. Pair with B5.
10. **A5 / B9** — Forum-thread (topic) routing. Any non-trivial Telegram
group install needs this.
11. **B11** — User snooze / mute via bot command. Relief valve when the
bridge gets too chatty.
12. **B7** — Bulk operations on trackers / targets / links. Operability
floor for any install with >10 trackers.
Everything else in Part B is upside, not a blocker.
+682
View File
@@ -0,0 +1,682 @@
# Frontend Production-Readiness Review
Scope: `frontend/src/**` (~26k lines, Svelte 5 runes + SvelteKit). `npm run check`
passes with exit code 0. The codebase is in good shape overall - i18n EN/RU keys
are 1:1 in sync (1466 each), Modal/Snackbar overlays follow the `position:fixed`
+ `z-index:9999` convention, no `eval`, no `innerHTML`, no string-interpolated
`setTimeout`, and the sanitizer (`lib/sanitize.ts`) is a sound DOMParser-based
allowlist. The issues below are real production risks layered on top of an
otherwise clean architecture.
## Executive Summary
- **Auth tokens live in `localStorage`** (`lib/api.ts`). Any XSS that bypasses
the (good) `sanitizePreview` allowlist - or sneaks past it via a future code
path - exfiltrates both access and refresh tokens. There is no httpOnly-cookie
alternative, no token rotation on refresh failure, and `redirectToLogin` only
fires once per session (a leaked refresh token can outlive that flag).
- **One real provider-hardcoding violation** (`routes/actions/RuleEditor.svelte`)
breaks the "descriptors only" rule in CLAUDE.md item 8 and silently disables
the people/album picker for any non-Immich provider - every other page is
clean.
- **Caches duplicated into local `$state`** on `notification-trackers`,
`command-trackers`, and `command-template-configs` pages - the cache is
populated but the page never re-reads it, so cross-page mutations (search
palette pre-warming) won't update the list and cache `invalidate()` becomes
useless. Convention #4 says "always use cache".
- **Three CRUD pages refetch all entities after every mutation** (full
`await load()` after upsert/delete) instead of using `cache.upsert()`/
`remove()` - defeats the optimistic-cache design and produces visible flicker
on slow connections.
- **Floating async work + N+1 patterns**: `providers/+page.svelte` fires N
parallel health checks without an AbortController (state writes continue
after navigation); `bots/TelegramBotTab.svelte` does a sequential
`for (const trk of trackers) { await api('/listeners') }` loop.
- **`backup/+page.svelte` post-restart health poll** keeps recursing for up to
120s with no unmount guard - if the user navigates away mid-restart, the
recursive `setTimeout` chain keeps calling `fetch('/api/health')` until it
reloads the page out from under whatever route they're on.
- **`api()` 30s timeout is per-request, hard-coded, with no observability** -
long-running provider operations (Immich bulk fetch, full backup export) hit
it silently and surface as `AbortError` with no telemetry.
---
## CRITICAL
### C1. JWT tokens stored in `localStorage` - XSS-exfiltratable
[lib/api.ts:78-91](frontend/src/lib/api.ts#L78-L91)
```ts
function getToken(): string | null {
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
```
Both the short-lived access token and the long-lived refresh token sit in
`localStorage`. Any successful XSS - including a future template-preview path
that escapes `sanitizePreview`, a vulnerable third-party CodeMirror extension,
or a Telegram bot username that ends up unescaped somewhere - reads both with a
single `localStorage.getItem` call.
**Fix:** Move to httpOnly + Secure + SameSite=Strict cookies set by the backend.
If a cookie-based session is infeasible for the deployment model, at minimum
move the refresh token to an httpOnly cookie and keep only the short-lived
access token in memory (a module-level `let accessToken` is XSS-readable but
not persistent across reloads, which limits the exfiltration window).
### C2. Provider type hardcoded in `RuleEditor.svelte` (convention violation)
[routes/actions/RuleEditor.svelte:55-67](frontend/src/routes/actions/RuleEditor.svelte#L55-L67)
```ts
async function loadProviderData() {
if (actionType !== 'auto_organize') return;
const provider = providersCache.items.find((p: any) => p.id === providerId);
if (!provider || provider.type !== 'immich') return;
...
```
CLAUDE.md item 8 explicitly forbids `if (type === 'immich')` in components -
this is the canonical example. As written, adding a second provider with
auto-organize support (Google Photos, future SmugMug, etc.) is a silent no-op:
the form renders with empty people/album lists and gives no error.
**Fix:** Add an `actionTypes` / `peopleFilter` capability flag to
`ProviderDescriptor`, or add a `supportsAutoOrganize: boolean` discriminator,
then check `getDescriptor(provider.type)?.supportsAutoOrganize` instead of the
literal string.
---
## HIGH
### H1. Caches imported but copied into local `$state` - invalidation no-op
[routes/notification-trackers/+page.svelte:33](frontend/src/routes/notification-trackers/+page.svelte#L33)
[routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27)
[routes/command-template-configs/+page.svelte:51](frontend/src/routes/command-template-configs/+page.svelte#L51)
```ts
// notification-trackers - line 33
let allNotificationTrackers = $state<Tracker[]>([]);
// ...
[allNotificationTrackers] = await Promise.all([
api<Tracker[]>('/notification-trackers'),
...
]);
```
The cache modules expose `notificationTrackersCache`, `commandTrackersCache`,
and `commandTemplateConfigsCache` - populated by `+layout.svelte` on mount and
by the search palette - but these three pages don't read from them. They each
issue their own `api(...)` call and store the result locally. Side effects:
1. The cache shows stale data on every other page that reads it (dashboard nav
counts, search palette).
2. `commandTemplateConfigsCache.fetch(true)` is called on `command-template-configs`
`load()` but the result is then re-assigned from the function return value
into `allCmdTplConfigs` - the cache itself is updated, but the page has no
reactive link to it.
3. `cache.upsert()` / `cache.remove()` after mutations would short-circuit a
full refetch - but with the local-state copy, every save triggers a full
`await load()` (see H2).
**Fix:** Replace `let allX = $state([])` with `let allX = $derived(cache.items)`
(see how `targets/+page.svelte:147` does it correctly) and remove the parallel
`api()` call.
### H2. Full refetch after every mutation - cache.upsert/remove not used
[routes/providers/+page.svelte:238-250](frontend/src/routes/providers/+page.svelte#L238-L250)
[routes/actions/+page.svelte:139](frontend/src/routes/actions/+page.svelte#L139)
[routes/notification-trackers/+page.svelte:291](frontend/src/routes/notification-trackers/+page.svelte#L291)
[routes/targets/+page.svelte:476](frontend/src/routes/targets/+page.svelte#L476)
Every save/delete/toggle on these pages calls `cache.invalidate(); await load()`,
which re-fetches the entire list from the server. The cache exposes
`upsert(entity)` and `remove(id)` for exactly this case - the server already
returned the new entity (or 204), so the round-trip is wasted bandwidth and
produces a visible "list redraws" flash on slow links.
**Fix:** On POST/PUT response, `cache.upsert(savedEntity)`. On DELETE,
`cache.remove(id)`. Reserve `invalidate()` + `fetch()` for cases where the
mutation may have changed *other* entities (e.g. broadcast target updates
affect children).
### H3. Provider health checks fire-and-forget - leak past navigation
[routes/providers/+page.svelte:175-181](frontend/src/routes/providers/+page.svelte#L175-L181)
```ts
for (const p of allProviders) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
.catch(() => { health = { ...health, [p.id]: false }; });
}
```
No `AbortController`, no unmount guard. If the user navigates away while N
slow Immich/Gitea probes are inflight, every probe still resolves and tries to
write to the (now-detached) `health` `$state`. With Svelte 5 runes this won't
crash, but it does waste backend connections (Immich health checks call the
real API) and may trigger duplicate probes on quick back/forward navigation.
**Fix:** Pass `{ signal: controller.signal }` to `api()` (already supported -
see `lib/api.ts:150`), abort in `onDestroy`. Or use `cache.probeAll()` driven
from a single store so revisiting the page reuses the previous result.
### H4. Sequential awaits for independent fetches - N+1 in TelegramBotTab
[routes/bots/TelegramBotTab.svelte:215-223](frontend/src/routes/bots/TelegramBotTab.svelte#L215-L223)
```ts
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
try {
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some(...);
if (hasBot) matched.push(trk);
} catch (e) { console.warn(...); }
}
```
For a deployment with 20 command trackers, opening the listener section on a
bot triggers 20 serial `GET /command-trackers/{id}/listeners` requests -
visibly slow over a high-latency link.
**Fix:** Either expose a single backend endpoint
(`GET /command-trackers/listeners?bot_id=X`) or run the loop through
`Promise.all(trackers.map(trk => api(...).catch(() => null)))` and filter
afterwards.
### H5. Post-restart health poll keeps running after unmount
[routes/settings/backup/+page.svelte:117-139](frontend/src/routes/settings/backup/+page.svelte#L117-L139)
```ts
async function applyAndRestart(): Promise<void> {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
const startedAt = Date.now();
let attempts = 0;
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
}
```
The recursive `setTimeout(poll, 1000)` chain has no cancellation. If the user
navigates to another route between `apply-restart` and the next health probe,
the chain keeps firing for up to 120s and eventually calls
`window.location.reload()` from a route the user has since moved away from.
Side effects:
1. Unauthenticated `fetch('/api/health')` calls keep going while the user is
on `/login`.
2. A user who hit "restart later" on a different tab will still get reloaded
from the original tab's poll.
**Fix:** Capture `controller = new AbortController()` and pass to `fetch`,
`onDestroy(() => controller.abort())`. Also store the timeout handle and
`clearTimeout` it on destroy.
### H6. Token refresh races with logout in a sneaky edge
[lib/api.ts:97-127](frontend/src/lib/api.ts#L97-L127)
The dedupe via `refreshPromise` is correct *for the refresh itself*, but the
outer `api()` reads `getToken()` before awaiting `refreshAccessToken()`. Three
concurrent requests that all 401 will all queue on the same refresh promise,
then *all* retry - fine. But if the refresh succeeds and an unrelated
`clearTokens()` (from `logout()`) fires between the refresh resolving and the
retry running, the retry uses an empty `Authorization: Bearer ` header. The
result is "ApiError: HTTP 401" surfaced via snackbar even though the redirect
to `/login` already happened.
**Fix:** Either re-check `isAuthenticated()` immediately before the retry, or
make `clearTokens()` cancel an inflight `refreshPromise`.
### H7. `AuthRedirectError` is thrown but not consistently caught
[lib/api.ts:165-170](frontend/src/lib/api.ts#L165-L170)
Most pages use the pattern `catch (err: unknown) { snackError(errMsg(err)); }` -
which catches `AuthRedirectError` too and shows "Unauthorized - redirecting
to login" in a snackbar that the user sees *as* the route changes. The error
class exists specifically to be distinguished, but only one or two call sites
actually check `instanceof AuthRedirectError` before showing a snackbar.
**Fix:** Make `errMsg()` (or a new helper) return `null` for `AuthRedirectError`
and have snackbar helpers ignore null messages. Or filter in the snackbar
store.
### H8. `api()` JSON-decode failure path swallowed silently
[lib/api.ts:189](frontend/src/lib/api.ts#L189)
```ts
return res.json();
```
When the backend returns a `200 OK` with a non-JSON body (proxy error page,
HTML 502 from a misconfigured reverse proxy in front), `res.json()` rejects
with a `SyntaxError: Unexpected token < in JSON at position 0`. The page
shows the raw parser message in a snackbar, which is confusing UX.
**Fix:** Wrap `res.json()` in try/catch and throw a typed `ApiError("Backend
returned non-JSON response", 502)` so the UI can show a clean message.
### H9. Email/Matrix bot tabs strip secrets via `as any`
[routes/bots/EmailBotTab.svelte:84](frontend/src/routes/bots/EmailBotTab.svelte#L84)
[routes/bots/MatrixBotTab.svelte:79](frontend/src/routes/bots/MatrixBotTab.svelte#L79)
```ts
if (!body.smtp_password) delete (body as any).smtp_password;
if (editingMatrix && !body.access_token) delete (body as any).access_token;
```
The `as any` bypass exists because the body type doesn't allow `delete` on a
required field. The intent - "don't send a blank secret which would overwrite
the stored one" - is correct, but the cast hides a real risk: if the field
name ever changes (`smtp_password` -> `smtpPassword`), the `delete` is a no-op
and the blank field is sent.
**Fix:** Build `body` as `Partial<...>` from the start and only conditionally
include the secret field.
### H10. `template-configs` hardcodes a slot name
[routes/template-configs/+page.svelte:228](frontend/src/routes/template-configs/+page.svelte#L228)
```ts
.map(s => ({ key: s.name, label: ..., rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
```
Special-casing one Immich slot name inside a provider-agnostic component is
the same pattern CLAUDE.md item 8 forbids for components, scoped to template
configs. Other providers' "large" slots (Gitea PR descriptions, Planka card
content) would render in 3-row editors that the author probably didn't intend.
**Fix:** Add a `rows?: number` field to the backend slot definition and read
it via `notification_slots[].rows`.
---
## MEDIUM
### M1. Three placeholder strings hardcoded English in shared components
[lib/components/EntitySelect.svelte:18](frontend/src/lib/components/EntitySelect.svelte#L18)
[lib/components/IconGridSelect.svelte:16](frontend/src/lib/components/IconGridSelect.svelte#L16)
[lib/components/MultiEntitySelect.svelte:16](frontend/src/lib/components/MultiEntitySelect.svelte#L16)
```ts
placeholder = 'Select...',
```
These defaults render `Select...` in RU locale when a caller doesn't pass an
explicit placeholder. The convention (CLAUDE.md item 5) prescribes plain text
selectors but says nothing about translation - these still need to flow through
`t()`.
**Fix:** Move the default into the template: `placeholder = $props().placeholder
?? t('common.selectPlaceholder')`, with `common.selectPlaceholder` added to
both locales.
### M2. `EntitySelect.noneLabel` defaults to a decorative em-dash literal
[lib/components/EntitySelect.svelte:20](frontend/src/lib/components/EntitySelect.svelte#L20)
```
noneLabel = (em-dash literal),
```
CLAUDE.md item 5 calls out decorative dashes specifically. `LinkedTargetsSection`
already overrides this with `t('common.noneDefault')` (good), but other
consumers that do not override get the bare em-dash. It also fails the
localizable smell test.
**Fix:** Default to `t('common.none')`.
### M3. `lib/auth.svelte.ts` logout does a full page reload, losing UX continuity
[lib/auth.svelte.ts:54-61](frontend/src/lib/auth.svelte.ts#L54-L61)
```ts
export function logout() {
clearTokens();
clearAllCaches();
user = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
```
`window.location.href` triggers a hard reload - the SvelteKit router exists
specifically to avoid this. Side effects: any inflight requests get cancelled
without proper cleanup, the splash-loader flashes between the two pages, and
the search-palette / overlays do not get a chance to close gracefully.
**Fix:** `goto('/login', { invalidateAll: true, replaceState: true })`.
### M4. `+layout.svelte` auto-expand `$effect` writes during read
[routes/+layout.svelte:336-342](frontend/src/routes/+layout.svelte#L336-L342)
The effect reads `expandedGroups` (via `expandedGroups[entry.key]`) and writes
to `expandedGroups`. Svelte 5 dedupes the write back to the same set of keys,
but the pattern is fragile - adding any side effect that re-derives from
`expandedGroups` here would loop. It also persists to localStorage in
`toggleGroup` but not from this effect - so auto-expansion stays in memory only.
**Fix:** Compute the next state in a single pass and write once; either
include the localStorage save, or move the auto-expand into the initial
hydration block.
### M5. `commandTemplateConfigsCache.fetch(true)` result discarded; cache populated but unused
[routes/command-template-configs/+page.svelte:208](frontend/src/routes/command-template-configs/+page.svelte#L208)
The `Promise.all` destructures `cfgs` from `commandTemplateConfigsCache.fetch(true)`
but then writes `allCmdTplConfigs = cfgs` instead of $derived-reading the cache.
The cache is updated (good) but this page never reads it (bad - see H1).
**Fix:** Same fix as H1 - use `$derived(commandTemplateConfigsCache.items)`.
### M6. Dashboard search debounce timeout not cleared on filter change
[routes/+page.svelte:268-272](frontend/src/routes/+page.svelte#L268-L272)
If the user changes the type/provider filter (`applyFilters` runs synchronously
from the `$effect` at line 249) while a search debounce is pending, the pending
timeout still fires 300ms later and triggers an identical request. Not a leak,
just a wasted call.
**Fix:** Clear `searchTimeout` from `applyFilters()` as well.
### M7. Dashboard `Promise.all` destructure uses empty middle slot
[routes/+page.svelte:283-287](frontend/src/routes/+page.svelte#L283-L287)
```ts
const [statusRes, , chartRes] = await Promise.all([
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<{ days: ... }>('/status/chart'),
]);
```
The empty middle slot is brittle - anyone reordering for readability silently
swaps `statusRes` and `chartRes`. Trivially avoided.
**Fix:** Either await `providersCache.fetch()` separately (it caches anyway),
or `const [statusRes, _providers, chartRes] = ...` with an explicit `_providers`
local.
### M8. `actions/+page.svelte` derives `actionTypes` from a function-in-derived
[routes/actions/+page.svelte:78-81](frontend/src/routes/actions/+page.svelte#L78-L81)
```ts
let actionTypes = $derived((() => {
const caps = capabilitiesCache.items[selectedProviderType];
return caps?.action_types || [];
})());
```
The IIFE is unnecessary; `$derived` already runs the expression on every
dependency change. Reads as a refactor leftover.
**Fix:** `let actionTypes = $derived(capabilitiesCache.items[selectedProviderType]?.action_types ?? []);`
### M9. `RuleEditor.svelte` mutates rule object in `toggleRule` then sends to API
[routes/actions/RuleEditor.svelte:105-108](frontend/src/routes/actions/RuleEditor.svelte#L105-L108)
```ts
async function toggleRule(rule: ActionRule) {
rule.enabled = !rule.enabled;
await updateRule(rule);
}
```
Direct mutation of the prop violates the immutability rule (coding-style.md).
If the API call fails, the local state is already flipped - the UI shows the
new value even though the server still has the old one.
**Fix:** `await updateRule({ ...rule, enabled: !rule.enabled })`. After
successful response, `await loadRules()` (already happens) re-syncs.
### M10. `+layout.svelte` filter functions use `as any[]` four times
[routes/+layout.svelte:145-151](frontend/src/routes/+layout.svelte#L145-L151)
```ts
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
```
The cast exists because `filterById<T extends { provider_id?: number }>` is
narrower than the cache item types. The proper fix is a single base interface
`{ provider_id?: number }` on the relevant types so the cast goes away.
### M11. `setLocale` does not update `<html lang>` attr
[lib/i18n/index.svelte.ts:31-36](frontend/src/lib/i18n/index.svelte.ts#L31-L36)
Screen readers and browser translation extensions rely on `<html lang="en">`.
The app never sets it, so switching to RU leaves accessibility tooling thinking
the page is still English.
**Fix:** `document.documentElement.lang = locale` in `setLocale`.
### M12. `Modal.svelte` focus restore does not verify element still in DOM
[lib/components/Modal.svelte:43-45](frontend/src/lib/components/Modal.svelte#L43-L45)
If the previously focused element has been removed from the DOM between modal
open and close (common with optimistic UI updates that rerender the source
button), `.focus()` is a silent no-op on a detached node. Focus ends up on
`<body>` and the next Tab restarts from the top of the page.
**Fix:** `if (... && document.contains(previouslyFocused)) previouslyFocused.focus()`,
else focus a sensible fallback (the trigger that opened the page).
### M13. TimezoneSelector ticks at 1s - wakes the event loop forever
[lib/components/TimezoneSelector.svelte:33-37](frontend/src/lib/components/TimezoneSelector.svelte#L33-L37)
```ts
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
```
A 1Hz tick is fine for visible UI; the issue is it keeps running even when
the selector dropdown is closed (the time display is only visible when the
dropdown is open). Battery impact is non-trivial on mobile for what is
essentially a hidden component.
**Fix:** Start/stop the interval based on `open` state, or use
`requestAnimationFrame` driven by `IntersectionObserver`.
### M14. Backup file download builds blob from JSON without size guard
[routes/settings/backup/+page.svelte:269-281](frontend/src/routes/settings/backup/+page.svelte#L269-L281)
```ts
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
```
For a deployment with hundreds of providers/trackers, the JSON serialization
of the entire backup happens in-memory in a single string before the Blob
constructor - wasted memory peak and a frozen tab on slow machines. Worse,
`api()` parses the JSON and then `JSON.stringify` re-serializes it.
**Fix:** Use `fetchAuth()` for the download path and pipe the response stream
straight into a Blob (`new Blob([await res.arrayBuffer()])`).
### M15. Modal focus-trap query selector includes disabled inputs
[lib/components/Modal.svelte:62-67](frontend/src/lib/components/Modal.svelte#L62-L67)
Re-querying the DOM on every Tab keystroke is OK but means disabled inputs
(common in long forms with submit-in-progress) are included in the trap and
focus can land on them. The selector should add `:not([disabled])`.
### M16. i18n resolve uses any for the recursion accumulator
[lib/i18n/index.svelte.ts:55-62](frontend/src/lib/i18n/index.svelte.ts#L55-L62)
```ts
function resolve(obj: any, path: string): string | undefined {
```
`obj: unknown` plus a runtime check would let TS narrow `current` properly and
catch the case where someone accidentally passes a `string` (returns undefined
silently today).
### M17. Tracker name auto-set string concat - English-only
[routes/notification-trackers/+page.svelte:82-84](frontend/src/routes/notification-trackers/+page.svelte#L82-L84)
[routes/command-trackers/+page.svelte:69-71](frontend/src/routes/command-trackers/+page.svelte#L69-L71)
```ts
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
form.name = provider ? `${provider.name} Commands` : 'Commands';
```
Defaults the tracker name to "Provider Name Tracker" / "Provider Name Commands"
- only English. Russian users get an English suffix on the auto-generated
name. Inconsistent with the rest of the i18n discipline.
**Fix:** Use `t('notificationTracker.defaultName').replace('{name}', provider.name)`.
### M18. topbar-action store not cleared on auth state change
[routes/providers/+page.svelte:160-167](frontend/src/routes/providers/+page.svelte#L160)
Each page sets a topbar CTA in `onMount` and clears it in `onDestroy`. If
`logout()` is called from inside the page (via the search palette, etc.), the
page never destroys cleanly and the topbar action sticks into the login screen.
Defensive `topbarAction.clear()` in `logout()` would plug this.
### M19. Many `: any` and `as any` types in critical paths
[routes/users/+page.svelte:62](frontend/src/routes/users/+page.svelte#L62)
[routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27)
[routes/providers/+page.svelte:179](frontend/src/routes/providers/+page.svelte#L179)
[lib/providers/types.ts:120](frontend/src/lib/providers/types.ts#L120)
64 occurrences of `: any` / `as any` across 20 files. None are in
security-sensitive paths, but they remove type safety in exactly the call
sites that shape API requests (`body: any = { ... }`). Recommended cleanup
task, not a blocker.
---
## LOW
### L1. +page.svelte event types hardcoded in three parallel maps
[routes/+page.svelte:475-512](frontend/src/routes/+page.svelte#L475-L512)
`eventLabels`, `eventIcons`, and `eventGradients` are three parallel dicts
keyed by the same set of strings. Adding a new event type requires editing
three places (plus i18n). A single `EVENT_META` object would be more
maintainable.
### L2. TestMenu.svelte uses z-index 9998 instead of 9999
[routes/notification-trackers/TestMenu.svelte:25](frontend/src/routes/notification-trackers/TestMenu.svelte#L25)
```svelte
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
```
The convention says 9999 for overlays. Using 9998 was probably intentional
(so the menu sits above the backdrop), but the cleaner pattern is to give the
backdrop a slightly lower stacking context inside the same parent.
### L3. console.warn left in production-bound code
14 `console.warn`/`console.error` occurrences. Most are guarded by a
"failed to load" + UI fallback - legitimate debug noise. Recommend wiring to
a structured logger before public release; current state is acceptable for an
internal tool but spam-prone in DevTools.
### L4. Dashboard setTimeout(animateCount, 200) is uncancelled
[routes/+page.svelte:290-299](frontend/src/routes/+page.svelte#L290-L299)
The 200ms delay before triggering count animations is uncancelled. Navigating
away during the first 200ms means the count animation `requestAnimationFrame`
chain still runs against a stale `status` reference. Cosmetic only.
### L5. app.html inline theme bootstrap reads localStorage without try/catch
[src/app.html:12](frontend/src/app.html#L12)
Theme is hydrated synchronously in `<head>` to avoid FOUC - fine - but if
localStorage is blocked (Safari private mode, some enterprise policies) the
inline script throws and the rest of the head bootstrap may be skipped.
### L6. EventChart computes activeTypes and hasData from same loop twice
[lib/components/EventChart.svelte:46-49](frontend/src/lib/components/EventChart.svelte#L46-L49)
`hasData` and `activeTypes` traverse the same data twice. Single-pass
derivation would be cheaper for the rare "many days of events" case.
### L7. Single-letter t shadowing in +layout.svelte
`+layout.svelte:140` uses `for (const t of targets)` inside `navCounts`, which
shadows the imported i18n function `t`. Svelte 5 does not flag it (inner scope
wins), but it confuses search/grep and breaks IDE go-to-definition. Several
other pages use single-letter `t` as iteration var (`actions/+page.svelte`,
`command-trackers/+page.svelte`, `targets/+page.svelte`). Recommend `target` /
`tracker` for legibility.
---
## Notes & non-findings
- **Modal overlay convention** (CLAUDE.md #2): Modal.svelte, Snackbar,
IconPicker, IconGridSelect, MultiEntitySelect, EntitySelect, TimezoneSelector,
EventChart, Hint, SearchPalette, and TestMenu all use `position:fixed` with
`z-index: 9999` (or 9998 for the TestMenu backdrop - see L2). Convention
upheld.
- **@html usage** - only three call sites, all pipe through `sanitizePreview`,
which is a DOMParser-based allowlist limited to `B`, `I`, `CODE`, `PRE`, `A`,
`BR` with `https?://` href validation. Safe.
- **i18n parity**: EN and RU JSON have the exact same 1466 keys - no orphans.
- **Selector placeholders**: `LinkedTargetsSection` correctly uses
`t('common.noneDefault')`, no em-dash leaks in user-facing flows (only
defaults inside shared components - see M1/M2).
- **svelte-check passes** (exit 0) - no type errors at the strict level the
project compiles with.
- **No eval, new Function, or string-setTimeout**: dynamic code execution
surface is clean.
- **No var declarations**, no `==` (loose equality) outside generated CSS.
- **AbortController usage**: present in `lib/api.ts` for the canonical fetch
wrapper - the rest of the codebase could lean on it more (see H3, H5).
+436
View File
@@ -0,0 +1,436 @@
# Performance & Database Review — `service-to-notification-bridge`
**Scope:** entire repo at `c:\Users\Alexei\Documents\service-to-notification-bridge`
**Backend:** FastAPI + SQLAlchemy async + SQLModel on SQLite (Postgres-compatible URL, but only SQLite branch is exercised in code).
**Frontend:** SvelteKit 5 (runes) static build served by the same FastAPI process.
**Reviewer:** Claude Opus 4.7 (1M context)
---
## Executive summary
1. **Indexing is in good shape.** FK columns and the dashboard/webhook hot paths have explicit composite indexes (`ix_event_log_user_created`, `ix_event_log_user_event_type_created`, `ix_deferred_dispatch_status_fire_at`, partial `ux_deferred_dispatch_pending`). The bulk of the "missing index" risk is already mitigated.
2. **No real migration tool.** The project runs a hand-rolled, 1880-line, idempotent migration script on every boot. It works, but it's brittle, slow on cold start, has no down-migrations, and the table-rebuild branches lose indexes silently. Move to Alembic before the next major schema change.
3. **`create_all` is still the source-of-truth for new schemas** (engine.py:63). That's an anti-pattern next to migration tooling: schema drift can silently appear between fresh installs and upgraded installs.
4. **Two real N+1 risks remain.** `_tracker_response` (notification_trackers.py:286-291) calls `_tt_response` per link, and `_refresh_telegram_chat_titles` (scheduler.py:229) issues per-chat `getChat` calls without bot-level batching guards. The big one in `load_link_data` was already fixed (good).
5. **SQLite PRAGMAs are mostly right but pool sizing is wrong.** WAL, `synchronous=NORMAL`, FK enforcement, busy_timeout, temp_store=MEMORY are all set. Missing: `cache_size`, `mmap_size`. The async engine uses SQLAlchemy's default pool with multiple writer connections — under WAL that still serializes, but it raises spurious BUSY pressure on long transactions (see #M3).
6. **Event-log retention exists and is correct** (30-day default, cron at 03:00 UTC), but `retention_days=0` disables it silently and there is no archival, no per-tenant cap, no row-count metric exposed to operators.
7. **Memory leak risk: `_dirty_bots`, `_last_update_id`, `_last_webhook_reclaim_at`, `_adaptive_state`, `_adaptive_max_skip`** in command_sync.py, telegram_poller.py, scheduler.py are unbounded module-level dicts. In a long-running process they grow without ever shrinking when entities are deleted.
8. **Frontend has no virtualization on long lists** — dashboard event stream, tracker history, target list. On a tenant with thousands of events the dashboard `{#each status.recent_events}` (with `(event.id)` key) still renders the whole page-set into DOM and re-runs derivations on every refresh.
---
## CRITICAL
### C1. `create_all` is the schema-of-record for new installs ([engine.py:60](packages/server/src/notify_bridge_server/database/engine.py))
```python
async def init_db() -> None:
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
```
**What's wrong:** `init_db()` runs unconditionally on every boot before the migration script. New installs get the *current* model's CREATE TABLE statements — including FK declarations like `ondelete=SET NULL` — while upgraded installs only get what the (one-way) `migrate_*` scripts manage to inject via `ALTER TABLE`. Several migrations explicitly admit "this only takes effect on freshly created tables" (e.g. `migrate_eventlog_provider_fk` is a documented no-op). That means **the schema drift between a fresh install and a 6-month-old install is real and undocumented.**
**Impact:** stability — subtle bugs that reproduce only on upgraded installs (FK enforcement, cascade behavior, partial UNIQUE indexes); ops — restoring a backup from a fresh install onto an upgraded box, or vice-versa, can change observable behaviour.
**Fix:**
1. Adopt Alembic with autogenerate-from-models, lock the baseline migration to the current `SQLModel.metadata`, and stop calling `create_all` in production startup.
2. Keep the hand-rolled `migrate_*` chain as legacy data-migrations only (idempotent, runs once, then removed).
3. Add a CI check: spin up empty DB → run migrations → diff against `SQLModel.metadata` → fail if non-empty.
---
### C2. `migrate_schema` runs ~30+ idempotent `PRAGMA table_info` + ALTER probes on every cold start ([migrations.py:67-427](packages/server/src/notify_bridge_server/database/migrations.py))
`_has_column` issues a `PRAGMA table_info('<table>')` per check; `migrate_schema` calls it dozens of times serially inside one transaction. On a cold start this is the dominant boot latency. Worse, it forces a write txn on every boot even when nothing changes (because each migration opens `engine.begin()`).
**Impact:** startup cost — visible on Raspberry-Pi / NAS deployments; SQLite WAL checkpoint pressure on every boot when nothing changed; readiness probe grace window must accommodate this.
**Fix:**
1. Wire `schema_version` (already exists, `CURRENT_SCHEMA_VERSION=1`) as a real short-circuit — at the top of every `migrate_*`, return immediately if `schema_version >= N` for that migration.
2. Cache `PRAGMA table_info` results within a single migration run.
3. Better long-term: replace with Alembic; you already have the version table.
---
### C3. `_install_sqlite_pragmas` only fires on engine-pool `connect`, not when SQLAlchemy reuses pooled connections from a different event loop ([engine.py:18-38](packages/server/src/notify_bridge_server/database/engine.py))
The `@event.listens_for(engine.sync_engine, "connect")` hook only runs at connection creation. The default `aiosqlite` pool reuses connections — that's fine — but `connect_args["timeout"]=30` clashes with the in-PRAGMA `busy_timeout=10000` (10 s). Two different timeout settings is confusing and the lower wins.
**Impact:** stability under contention — under sustained writer contention you get `SQLITE_BUSY` *much* sooner than expected. The 30-s connect_args timeout is for connection *open*, the 10-s busy_timeout is what governs lock contention; users see "database is locked" errors after 10 s, not 30.
**Fix:** standardize on busy_timeout (raise to 30 s to match `connect_args`, or drop one and keep the other). Document the chosen value in a constant. Also add:
```python
cur.execute("PRAGMA cache_size=-65536") # 64 MiB negative = kibibytes
cur.execute("PRAGMA mmap_size=268435456") # 256 MiB
cur.execute("PRAGMA wal_autocheckpoint=1000")
```
The 100k-asset album write pattern (`asset_ids` JSON blob) benefits significantly from a larger page cache and mmap; current defaults force a lot of SQLite-internal I/O.
---
## HIGH
### H1. Frontend dashboard event-stream lacks virtualization & double-fetches on filter changes ([+page.svelte:739](frontend/src/routes/+page.svelte))
`{#each status.recent_events as event, i (event.id)}` is keyed (good), but the page renders every event row with rich nested components (`EventDetailModal`, `MdiIcon`, etc.) for every paginate-back/forward. There's no row virtualization and the same data fetches re-run on every filter mutation (search input has a 300 ms debounce in `onSearchInput`, but `filterEventType`, `filterProviderId`, `filterSort`, `refreshSeconds` do not).
**Impact:** UX — choppy on tenants with 50+ events/page, perceptible filter-flicker; CPU — derivation cost on every status refresh.
**Fix:**
1. Wrap the events list in a tiny windowing component (svelte-virtual or a simple offset/limit windowed view — the API already supports it).
2. Debounce the entire filter-change branch, not just the search input (`$effect(() => { if (settled) { reload() }})` with a 100 ms guard).
3. The provider count map (`provider_event_counts`) is computed server-side for *all* matching events on every page request; cache it for `(user_id, filters)` in a 30-s in-memory dict server-side (see also #M2).
---
### H2. `provider_event_counts` aggregate query runs unbounded GROUP BY on every dashboard request ([status.py:84-103](packages/server/src/notify_bridge_server/api/status.py))
```python
provider_counts_query = (
select(
EventLog.provider_id,
EventLog.provider_name,
func.sum(func.coalesce(EventLog.assets_count, 1)).label("total"),
)
.where(EventLog.user_id == user.id)
.group_by(EventLog.provider_id, EventLog.provider_name)
)
```
Every dashboard load (every 1060 s by default — see `refreshIntervalItems`) runs `GROUP BY provider_id, provider_name` over *every* event the user ever owned. At 90 days × ~1 event/min/tracker this is hundreds of thousands of rows scanned per refresh per logged-in user.
**Impact:** latency — SQLite forces a full table scan + sort here because the only composite index is `(user_id, event_type, created_at DESC)`; cost — burns CPU on the bridge box for a metric that changes very slowly.
**Fix:**
1. Add `ix_event_log_user_provider (user_id, provider_id)` so the GROUP BY can be index-only.
2. Cache the result for `(user_id, filter_signature)` for 30 s in the same in-memory cache as #H1.
3. Long-term: materialize per-provider counts into an `event_counter` table maintained by triggers or an APScheduler job. The dashboard then reads at most a dozen rows.
---
### H3. `_tracker_response` issues one query per tracker-target link ([notification_trackers.py:286-291](packages/server/src/notify_bridge_server/api/notification_trackers.py))
```python
async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> dict:
result = await session.exec(
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == t.id)
)
tracker_targets = [await _tt_response(session, tt) for tt in result.all()]
```
`_tt_response` (in notification_tracker_targets.py:12 — has 12 distinct `select`/`session.get` references) issues per-link follow-up SELECTs. Called from `create`, `update`, `delete` and `trigger` for a single tracker, so the practical N is small — but `_tt_response` is also called inside the bulk `list_notification_trackers` loop's downstream consumers, and any future bulk endpoint will multiply this badly.
**Impact:** latency on POST/PATCH responses; future regression risk.
**Fix:** rewrite `_tt_response` to accept pre-fetched maps (mirror the pattern in `dispatch_helpers.load_link_data`). Or, simpler: write a single eager-load helper using `selectinload(NotificationTrackerTarget.target)` once `relationship()` mappers are declared on the models.
---
### H4. `load_link_data` does not eagerly load target.config related entities — relies on `dict(target.config)` snapshotting ([dispatch_helpers.py:539-747](packages/server/src/notify_bridge_server/services/dispatch_helpers.py))
The function batch-loads receivers, telegram_chats, email_bots, matrix_bots up-front, but the broadcast-expansion branch in the active_links loop still issues `_resolve_target` per child target (line 715). That `_resolve_target` is called with all the pre-fetched maps, so it doesn't *query* per call — but it does build a fresh `target_config` dict per child. With a broadcast target containing 50 children fanning out 100 events/min this is constant garbage collection pressure.
**Impact:** GC pressure under load; not a correctness problem.
**Fix:** none required short-term. Long-term, add `selectinload` declarations on the relationship model so SQLAlchemy can co-fetch the chain. The code path is already well-batched.
---
### H5. `aiohttp.ClientSession` is constructed per-call inside `NotificationDispatcher._session_ctx` when no shared session is provided ([dispatcher.py:117-123](packages/core/src/notify_bridge_core/notifications/dispatcher.py))
```python
@contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
if self._shared_session is not None and not self._shared_session.closed:
yield self._shared_session
return
async with _new_session() as session:
yield session
```
In server-side code paths (watcher, event_dispatch, deferred_dispatch) a shared session is always passed in, so this is harmless. But unit tests, the CLI, and any direct library user that instantiates `NotificationDispatcher` without a session pays the cost. Worse, the per-dispatch session creates a fresh TCP pool, fresh DNS resolver — defeating connection reuse to Telegram / Discord webhook hosts.
**Impact:** test slowness; correctness if a non-server consumer ever ships.
**Fix:** require the `session` parameter (`session: aiohttp.ClientSession` not `| None`). Or have the dispatcher lazily attach to a module-level `_default_session` cached by event loop id.
---
### H6. `WebhookPayloadLog` is pruned per-insert via a sub-select but the prune query has no UNIQUE/partial protection against duplicate inserts ([webhooks.py:404-418](packages/server/src/notify_bridge_server/api/webhooks.py))
The "keep newest `max_count` per provider, delete the rest" pattern uses `select(...).order_by(created_at DESC).limit(max_count)` as a subquery. Under SQLite this materializes the top-N then negates it — fine when max_count is 20. But this runs on every inbound webhook. For a busy Gitea/HA installation that's 60+ writes/min, each with a delete-by-sub-select. The `ix_webhook_payload_log_provider_created` index makes the read cheap, but the DELETE still rewrites pages.
**Impact:** write amplification on busy webhook tenants.
**Fix:** keep the prune but make it probabilistic — only run with `random.random() < 0.1` (10% chance per insert). The cap still holds in steady state, but the per-write cost drops 10×.
---
### H7. No retention/archival for `notification_tracker_state` and `deferred_dispatch` "fired"/"dropped" rows ([scheduler.py:332-364](packages/server/src/notify_bridge_server/services/scheduler.py))
`_cleanup_old_events` deletes `event_log`, `webhook_payload_log`, `action_execution` older than retention days. `deferred_dispatch` rows with `status IN ('fired', 'dropped')` are never deleted. `notification_tracker_state.asset_ids` for an immich tracker watching a deleted collection is also never reaped.
**Impact:** unbounded growth on long-running installs; `asset_ids` JSON blobs can be megabytes per collection.
**Fix:** extend `_cleanup_old_events` to also delete `DeferredDispatch.status != 'pending' AND fired_at < cutoff`. Add a separate housekeeping job that prunes `NotificationTrackerState` rows whose `collection_id` is no longer in `NotificationTracker.collection_ids`.
---
## MEDIUM
### M1. Sentinel value `bot_id=0` is a footgun ([models.py:69-73](packages/server/src/notify_bridge_server/database/models.py))
```python
# bot_id=0 is a sentinel meaning "Telegram has not yet returned a numeric
# ID for this bot" (i.e. token never validated). Multiple unverified bots
# may legitimately carry 0, so we only enforce uniqueness for non-sentinel
# values via a partial index added in migrate_uniqueness_constraints.
bot_id: int = Field(default=0, index=True)
```
Sentinel values on indexed columns hurt index selectivity (every unvalidated bot is the same row from the planner's perspective) and create maintenance burden. Worse, every code path that looks up by `bot_id` must remember to filter `bot_id != 0`.
**Impact:** maintainability; latent bug surface (one missed `!= 0` filter and an unverified bot is silently re-used).
**Fix:** change `bot_id: int | None` defaulting to None, drop the sentinel.
---
### M2. No request-scoped cache for `user.id` lookups inside one request ([api/*.py, throughout](packages/server/src/notify_bridge_server/api/))
The same `get_current_user` dependency runs JWT validation + a `session.get(User, id)` on every request. Many endpoints then do their *own* `user.id`-filtered SELECTs. There is no per-request memoization of the User row.
**Impact:** one extra SELECT per request, mostly noise — but it's free to fix.
**Fix:** in `get_current_user`, cache the User on `request.state.user`. Routes that take `user: User = Depends(...)` are unchanged.
---
### M3. SQLAlchemy async pool defaults serialize SQLite writers but the engine allows multiple connections ([engine.py:41-57](packages/server/src/notify_bridge_server/database/engine.py))
`create_async_engine` for SQLite defaults to a `StaticPool` of size 1 in newer SQLAlchemy versions, but older versions / different `aiosqlite` versions can default to `NullPool` (one connection per request) or a small QueuePool. The code does not pin this explicitly. Under WAL, multiple readers are fine but only one writer can hold the txn at a time — so a slow writer just makes other connections block on `busy_timeout`.
**Impact:** unpredictable behaviour across SQLAlchemy versions; sporadic `SQLITE_BUSY` under load.
**Fix:** explicitly configure the pool:
```python
from sqlalchemy.pool import StaticPool, AsyncAdaptedQueuePool
_engine = create_async_engine(
url,
echo=settings.debug,
pool_pre_ping=True,
connect_args=connect_args,
poolclass=AsyncAdaptedQueuePool,
pool_size=5,
max_overflow=10,
pool_recycle=3600,
)
```
For Postgres compatibility leave these as-is; for SQLite the right value is `StaticPool` + `connect_args={"check_same_thread": False}` to share one connection across the event loop (this is the supabase/pgbouncer pattern adapted for sqlite-async).
---
### M4. `_refresh_telegram_chat_titles` issues per-chat HTTP without per-bot bucketing ([scheduler.py:229-329](packages/server/src/notify_bridge_server/services/scheduler.py))
The job builds `tasks` as a flat list across all bots and runs them under a global `Semaphore(10)`. A bot with 50 chats and a slow Telegram response (rare but happens) can monopolize all 10 slots, starving every other bot. The semaphore should be per-bot.
**Impact:** the daily refresh can take much longer than intended on a multi-bot install with one degraded bot.
**Fix:** create one semaphore per bot:
```python
sems = {bot_id: asyncio.Semaphore(_CHAT_SYNC_CONCURRENCY) for bot_id in bot_tokens}
```
---
### M5. `event_log.collection_name.contains(search)` triggers full table scan on filter ([status.py:69-75](packages/server/src/notify_bridge_server/api/status.py))
The dashboard search input runs four `.contains(search)` clauses ORed together — these become `LIKE '%search%'` and cannot use a regular B-tree index. With 100k+ event_log rows the dashboard search becomes a multi-second operation.
**Impact:** UX — search feels broken on large installs; CPU on the bridge box.
**Fix:**
1. Limit the search to the most recent N days (e.g. retention/3) — most users only search recent events.
2. Add a SQLite FTS5 virtual table mirroring event_log's text columns, sync via triggers. Searches use `MATCH 'foo'` which is sub-millisecond on million-row tables.
---
### M6. `DeferredDispatch.event_payload` JSON blob can grow unbounded per row ([models.py:639-659](packages/server/src/notify_bridge_server/database/models.py), [deferred_dispatch.py:188-298](packages/server/src/notify_bridge_server/services/deferred_dispatch.py))
The asset-coalescing union path appends every new asset's full dict (filename, urls, tags, extra metadata) into `event_payload["added_assets"]`. A mass-import that adds 50k photos during a quiet window means one DeferredDispatch row with 50k asset entries.
**Impact:** memory blow-up at drain time (the whole JSON is parsed via `deserialize_event` into a Python list of `MediaAsset` dataclasses); could trip the drain timeout (`_DRAIN_DISPATCH_TIMEOUT_SECONDS=120`) on legitimate workloads.
**Fix:** cap the union at e.g. 500 assets per row; when crossed, emit a "more_truncated" sentinel into `payload["extra"]` so the rendered template can show "+45000 more". The `apply_tracking_display_filters` `max_assets_to_show` does cap it for delivery, but the *stored* payload is uncapped.
---
### M7. Per-tick `await get_app_timezone(session)` reads from the DB on every dispatch ([dispatch_helpers.py:146-150](packages/server/src/notify_bridge_server/services/dispatch_helpers.py))
Each tracker tick, each webhook, each defer evaluation calls `get_app_timezone` which calls `get_setting(session, "timezone")` which is a SELECT. The timezone setting rarely changes (manual setting), but the SELECT runs constantly.
**Impact:** noise on otherwise good caching.
**Fix:** cache the timezone in a module-level `(value, expires_at)` tuple with 60-s TTL, invalidated by `reschedule_cron_jobs_for_timezone_change`.
---
### M8. Unbounded in-memory dictionaries with no TTL or capacity ([scheduler.py:67-72](packages/server/src/notify_bridge_server/services/scheduler.py), [telegram_poller.py:31-35](packages/server/src/notify_bridge_server/services/telegram_poller.py), [command_sync.py:25](packages/server/src/notify_bridge_server/services/command_sync.py))
```python
_adaptive_state: dict[int, dict[str, int]] = {}
_adaptive_max_skip: dict[int, int] = {}
_last_update_id: dict[int, int] = {}
_last_webhook_reclaim_at: dict[int, float] = {}
_dirty_bots: dict[int, float] = {}
```
Each is keyed by tracker_id / bot_id. When a tracker or bot is deleted, the cleanup paths (`unschedule_tracker`, etc.) do remove some entries — but not all. `_last_update_id`, `_last_webhook_reclaim_at` are never cleared on bot deletion.
**Impact:** slow memory leak in long-running processes that create+delete trackers/bots frequently (e.g. test environments).
**Fix:** on tracker/bot deletion, explicitly clear all module dicts that key by that id. Or, simpler, switch each to `weakref.WeakValueDictionary` once the entity has a Python object representation, or to a TTLCache.
---
### M9. Bulk insert pattern in migrations uses one-statement-per-row ([migrations.py:566-588](packages/server/src/notify_bridge_server/database/migrations.py))
`migrate_tracker_targets` issues `INSERT INTO ... VALUES (...)` per row in a Python for-loop. On a tenant with 10k+ legacy rows this is slow even inside a single transaction.
**Impact:** one-shot, but rough on upgrade for big tenants.
**Fix:** use `executemany` / batch INSERTs:
```python
await conn.execute(text("INSERT INTO ... VALUES (...)"), batch_params)
```
This is mostly historical (the migration is idempotent and skipped on subsequent runs), but worth fixing if you're touching the file.
---
### M10. Missing index on `notification_tracker_state(notification_tracker_id, collection_id)` ([models.py:454-478](packages/server/src/notify_bridge_server/database/models.py))
`check_tracker` reads state per tracker; the existing `ix_notification_tracker_state.notification_tracker_id` index (declared via `index=True`) supports that. But every state read is `WHERE tracker_id = ? AND collection_id = ?` (implicitly via the resulting dict). A composite would help; SQLite can do index-only scans here.
**Impact:** small. SQLite's index intersection plus the fact that one tracker typically has <20 collections makes this a minor win.
**Fix:** add `(notification_tracker_id, collection_id)` composite index to the `_INDEXES` list.
---
## LOW
### L1. `SELECT *` semantics from `select(Model)` ORM is unavoidable but verbose ([throughout services/, api/])
SQLModel's `select(ModelClass)` is effectively `SELECT all columns`. For wide rows like `TrackingConfig` (~70 columns of boolean flags) that's a lot of bytes per dispatch evaluation. There are no API list endpoints that return `TrackingConfig` from a hot path, so this is mostly cosmetic — but for pages that only need a handful of columns (e.g. `status.py`'s `tracker_id, name` map) the explicit-column form is already used. Continue that pattern.
---
### L2. `EventLog.details` JSON dict is reconstructed on every dashboard read ([status.py:258](packages/server/src/notify_bridge_server/api/status.py))
`details: e.details or {}` serializes the JSON every time. SQLite returns this as a parsed Python dict already (JSON column), so the cost is low; just a note that this is a hot path.
---
### L3. `event_log.collection_id` and `details` have no indexes; some webhook commands filter on them ([commands/immich/events.py:43](packages/server/src/notify_bridge_server/commands/immich/events.py))
The history-by-tracker endpoint uses the composite `ix_event_log_user_event_type_created` plus a hit on `notification_tracker_id` — fine. But `events.py`'s "last assets_added for this collection" queries (`event_type='assets_added' AND collection_id=?`) cannot use any current index optimally.
**Fix:** add `(event_type, collection_id, created_at DESC)` if these queries are called by users frequently (Telegram `/assets <album>` etc.).
---
### L4. JSON column types not declared with `JSONB` semantics ([models.py: many](packages/server/src/notify_bridge_server/database/models.py))
SQLite has only `JSON` (text storage with `json_valid` checks). On Postgres you'd want `JSONB`. The codebase uses `Column(JSON)` from SQLModel which maps to native `JSONB` on Postgres — that's correct. No action needed.
---
### L5. The `setup` lifespan runs migrations *inside* the FastAPI lifespan synchronously ([main.py:62-122](packages/server/src/notify_bridge_server/main.py))
The migrations + seeds + scheduler boot all run before `_READY = True`. On a cold start with a big DB this can take 10+ s during which `/api/ready` returns 503. That's correct, but `/api/health` is also un-reachable because uvicorn hasn't started the workers yet (lifespan blocks startup). For orchestrators that probe `/api/health`, this means startup-grace must be tuned.
**Fix:** start the HTTP listener first, run migrations as a background task, expose readiness flag through `/api/ready` only.
---
### L6. `ServiceProvider.config`, `NotificationTarget.config`, `Tracker.filters` JSON columns store secrets unencrypted ([models.py:42, 349, 399](packages/server/src/notify_bridge_server/database/models.py))
API keys, refresh tokens, webhook secrets, SMTP passwords all live in `config` JSON. Visible to anyone with DB read access. This is a known design trade-off (`backup_secrets_mode` controls export behaviour) but worth flagging.
**Fix:** out of scope for this review; consider an at-rest encryption layer keyed off `secret_key` (Fernet) for `config["api_key"]`, `config["password"]`, `access_token`, etc. — but only if your threat model justifies the operational cost.
---
### L7. Frontend `caches.svelte.ts` has 30-s TTL but no cross-tab invalidation ([entity-cache.svelte.ts:14](frontend/src/lib/stores/entity-cache.svelte.ts))
Two browser tabs editing the same entity will see stale data for up to 30 s in the other tab. No `BroadcastChannel` listener.
**Fix:** add a `BroadcastChannel('notify-bridge-cache')` that calls `cache.invalidate()` on receipt. ~15 lines.
---
### L8. `providersCache.invalidate(); await load()` is two-step ([providers/+page.svelte:238, 250](frontend/src/routes/providers/+page.svelte))
`invalidate()` + immediate `fetch(true)` race against any in-flight request; the deduplication map handles it, but the explicit `await load()` is essentially `fetch(true)` directly. Simpler:
```typescript
providersCache.set(updatedList); // or fetch(true)
```
Cosmetic.
---
### L9. `details["dispatch_status"]` is a string enum but not declared as one ([deferred_dispatch.py:619-624](packages/server/src/notify_bridge_server/services/deferred_dispatch.py))
`dispatch_status` takes values `"deferred"`, `"deferred_then_dropped"`, `"deferred_then_failed"`, `"delivered_after_quiet_hours"`, `"dropped_quiet_hours_nondeferrable"`. They're scattered as string literals. The dashboard renders them.
**Fix:** declare an `Enum` once and import from both server and frontend types.
---
### L10. No DB connection used by `/api/health` ([main.py:270-274](packages/server/src/notify_bridge_server/main.py))
`/api/health` returns instantly without checking the DB. That's correct for a liveness probe but the comment doesn't match common practice ("liveness = process up"). Pair this with #L5: orchestrators using `/api/health` for warm-up will mark the pod ready while migrations are still running.
**Fix:** keep liveness lightweight, document the readiness probe as the warm-up gate.
---
## Notes on what's already good
- Performance indexes (`_INDEXES` list) cover all the right hot paths.
- Composite `(status, fire_at)` index on `deferred_dispatch` plus partial unique `(link_id, collection_id, event_type) WHERE status='pending'` prevents the worst races.
- `load_link_data` is fully batched — the most complex hot path in the codebase looks clean.
- Shared `aiohttp.ClientSession` with DNS-rebinding-safe `PinnedResolver` is production-grade.
- Pre-migration `VACUUM INTO` snapshot is the right safety net for a hand-rolled migration chain.
- APScheduler defaults (`coalesce=True`, `misfire_grace_time=300`, `max_instances=1`) are correct production settings.
- Adaptive polling (skip-N-of-K when idle) with jitter is a thoughtful 4-tier scheduling design.
- Tracker cache (5-s TTL with explicit invalidation) and rendered-message per-locale cache are good fan-out optimizations.
- Migration idempotency is genuinely well-handled despite the rough tooling.
- Frontend `entity-cache` deduplication of in-flight requests is the right pattern.
---
## Priority recommendations (next 30 days)
1. **Adopt Alembic** (C1, C2) — eliminate `create_all` from prod, baseline the current schema, lock down new schema changes through autogenerate.
2. **Fix the dashboard aggregate query** (H1, H2, M5) — add the missing composite index, server-side cache the per-provider aggregate, virtualize the event list. This is the single biggest user-visible perf win.
3. **Cap `DeferredDispatch.event_payload` size + add retention for fired/dropped rows** (M6, H7) — closes off the worst-case memory and growth scenarios.
4. **Cleanup module-level dicts on entity deletion** (M8) — small fix, prevents a slow leak.
5. **Standardize SQLite PRAGMAs and pool config** (C3, M3) — predictable behaviour, fewer spurious BUSY errors.
---
*Reviewed against codebase at HEAD (`a20635a`).*
+312
View File
@@ -0,0 +1,312 @@
# Security Review — notify-bridge v0.8.1
Reviewer: security-reviewer (Opus 4.7) — 2026-05-22
Branch: master @ a20635a
Scope: `packages/server`, `packages/core`, `frontend/src`, `Dockerfile`, `docker-compose.yml`, `.gitea/workflows/`, env handling.
---
## Executive Summary
- **Overall posture is strong.** The project applies many non-obvious controls correctly: Jinja2 `SandboxedEnvironment` on every render path; `bcrypt` with a 72-byte length guard and constant-time login (dummy hash on missing user); JWT with `token_version` revocation; SSRF guard with CGNAT, IPv4-mapped-IPv6 unwrapping, and a `PinnedResolver` that defeats DNS rebinding; secret-masking log filter; path-traversal-safe backup file resolver; security headers + CSP; non-root Docker user; required `SECRET_KEY` >= 32 chars with a rejection list; non-default Telegram webhook secret enforced; HMAC signature checks on Gitea/Generic webhooks; provider-config secret masking on GET; ownership checks (`get_owned_entity`) on every parameterised route I sampled.
- **HIGH — Home Assistant `access_token` is not masked.** It is stored in `provider.config`, never added to the mask list in `_provider_response`, never added to the placeholder-drop list in `update_provider`. Any logged-in user can `GET /api/providers/{id}` and read their HA token in cleartext, and a partial save will wipe it. Trivial fix.
- **HIGH — Secrets at rest are plaintext.** Telegram bot tokens (`telegram_bot.token`), provider configs containing `api_key`/`api_token`/`webhook_secret`/`access_token`/SMTP passwords, and email-bot SMTP passwords are stored unencrypted in SQLite. Disk theft, an unrelated read primitive, or any backup leak exposes all credentials. The masking on the API is good UX, but the DB itself has no encryption-at-rest. The exported JSON backup respects a `secrets_mode` flag (good) but the live DB does not.
- **MEDIUM — Template-preview endpoints bypass the timeout/size watchdog.** `template_configs.preview_config`, `template_configs.preview_raw`, `command_template_configs.preview_raw`, and `notifier.send_test_template_notification` construct fresh `SandboxedEnvironment(autoescape=False)` instances and call `.render(...)` directly. The hardened helper `render_template()` (timeout, source cap, output cap, autoescape) is bypassed. A logged-in user can wedge a worker thread with `{% for i in range(10**8) %}x{% endfor %}`. Single-tenant deployment limits the blast radius, but the renderer should be the single chokepoint.
- **MEDIUM — Login rate limit is per-IP only.** `POST /api/auth/login @ 5/min` keys on `get_remote_address`. An attacker behind a proxy / NAT, or one that rotates source IPs (cheap on residential / cloud), trivially bypasses it. There is no per-username lockout, no exponential backoff, no captcha. Combined with no MFA, this leaves the admin account vulnerable to a slow online dictionary attack from a single password (8-char minimum, no complexity requirement).
- **LOW / INFO — Several smaller findings**: webhook payload logs persist source payload (now with key-level redaction, but the redactor is name-based and will miss high-entropy secret values in non-obvious keys); no replay protection on inbound webhooks (no nonce/timestamp window); the `/api/auth/setup` 3/min limit + JWT issuance race window is hardened with a transaction count guard (good), but the dummy bcrypt hash literal used for timing-equalisation is malformed and `bcrypt.checkpw` returns `False` via `ValueError` — the swallowed exception still equalises timing, but a maintainer could regress this; CSP allows `script-src 'unsafe-inline'` (necessary for SvelteKit hydration, acceptable risk acknowledged in code).
---
## Findings
### CRITICAL
_None found._
---
### HIGH
#### H-1. Home Assistant access_token leaked in provider GET responses
- CWE: CWE-522 (Insufficiently Protected Credentials), CWE-200 (Exposure of Sensitive Information)
- Files:
- [`packages/server/src/notify_bridge_server/api/providers.py:616-624`](../../packages/server/src/notify_bridge_server/api/providers.py) — `_provider_response` masks `("api_key", "api_token", "webhook_secret", "password", "client_secret", "refresh_token")` but **not** `access_token`.
- [`packages/server/src/notify_bridge_server/api/providers.py:399-405`](../../packages/server/src/notify_bridge_server/api/providers.py) — `update_provider` also omits `access_token` from the placeholder-drop list, so the response masking is consistent here, but if you fix one you must fix the other.
- Scenario: Any user authenticated to the bridge (any role) calls `GET /api/providers/{id}` for an HA provider they own and the response includes `config.access_token` in cleartext. The HA long-lived token grants full control of the user's Home Assistant instance (lights, locks, cameras, scripts, devices). In a multi-user deployment, even within the same admin account, a stolen JWT exfiltrates the HA token; in a single-user deployment, any read primitive (XSS via a future template feature, an MITM on an HTTPS misconfiguration) gives the same result.
- Remediation: Add `access_token` to both lists.
```python
# providers.py:_provider_response
for secret_field in (
"api_key", "api_token", "webhook_secret", "password",
"client_secret", "refresh_token", "access_token", # <-- add
):
...
# providers.py:update_provider
for secret_field in (
"api_key", "api_token", "webhook_secret", "password",
"client_secret", "refresh_token", "access_token", # <-- add
):
value = incoming.get(secret_field)
if isinstance(value, str) and value.startswith("***"):
incoming.pop(secret_field, None)
```
Better still: replace the hand-maintained tuple with a single module-level constant `_PROVIDER_SECRET_FIELDS` referenced from both call sites, plus a unit test that asserts every field declared on the per-provider Pydantic configs whose name appears in a denylist (`token`, `secret`, `password`, `key`, `credential`) is in the set. That prevents the next provider type from re-introducing the same gap.
#### H-2. Secrets stored in plaintext at rest
- CWE: CWE-312 (Cleartext Storage of Sensitive Information), CWE-256 (Plaintext Storage of a Password)
- Files:
- [`packages/server/src/notify_bridge_server/database/models.py:54-84`](../../packages/server/src/notify_bridge_server/database/models.py) — `TelegramBot.token: str`
- [`packages/server/src/notify_bridge_server/database/models.py:87-100`](../../packages/server/src/notify_bridge_server/database/models.py) — `MatrixBot` (access_token in config)
- `ServiceProvider.config: dict[str, Any]` (JSON column) holds Immich `api_key`, Gitea `webhook_secret` + `api_token`, Google Photos `client_secret` + `refresh_token`, HA `access_token`, etc.
- `EmailBot.smtp_password: str` (per [`api/email_bots.py:142`](../../packages/server/src/notify_bridge_server/api/email_bots.py))
- Scenario: An attacker who can read the SQLite file (compromised host, mis-permissioned backup volume, snapshot artifact in `data_dir/backups/`, leaked debug dump) gets every credential the bridge speaks: Telegram bot tokens (full bot control), Immich/Gitea/Planka API keys (read all photos / repos), Google Photos refresh tokens (long-lived, hard to revoke at scale), HA long-lived tokens (smart-home), SMTP passwords. The pre-migrate VACUUM-INTO snapshots (`packages/server/src/notify_bridge_server/database/snapshot.py`) inherit the same plaintext exposure and live alongside the active DB.
- Remediation options, in order of effort:
1. **Short term**: document the threat in `OPERATIONS.md`, enforce file-system permissions on `/data` (the Dockerfile chowns to appuser already, but the host bind-mount must be `chmod 700`), and ensure backups are encrypted at the storage layer (S3 SSE / Borg / restic).
2. **Better**: column-level encryption with a key derived from `NOTIFY_BRIDGE_SECRET_KEY` (or a separate `NOTIFY_BRIDGE_DB_ENCRYPTION_KEY`). Use the `cryptography` library's `Fernet` for each sensitive column; envelope the secret JSON keys, not the whole row, so `WHERE` clauses and existing migrations keep working. Add a one-shot migration that re-encrypts existing rows.
3. **Best**: encrypt with a KMS-backed key (HashiCorp Vault Transit, AWS KMS) and rotate per-secret data keys. This is overkill for a homelab homeserver-style deployment but mandatory if the bridge is ever multi-tenant.
- Skeleton for option 2:
```python
# new file packages/server/src/notify_bridge_server/security/secretbox.py
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
def _key() -> bytes:
# Derive a deterministic Fernet key from secret_key. Anyone with secret_key
# can decrypt — same threat model as JWT signing — but anyone with the DB
# alone cannot.
import base64, hashlib
h = hashlib.sha256(settings.secret_key.encode()).digest()
return base64.urlsafe_b64encode(h)
_fernet = Fernet(_key())
def encrypt_secret(plaintext: str) -> str:
return _fernet.encrypt(plaintext.encode()).decode()
def decrypt_secret(ciphertext: str) -> str:
return _fernet.decrypt(ciphertext.encode()).decode()
```
Apply at write time in `update_provider` / `create_provider`, decrypt at read time inside `make_immich_provider`, `make_gitea_provider`, the Telegram client constructor, etc. Add a migration that scans every `ServiceProvider.config` JSON and re-encrypts the listed keys in place.
---
### MEDIUM
#### M-1. Template preview endpoints skip the renderer watchdog
- CWE: CWE-400 (Uncontrolled Resource Consumption), CWE-1333 (Inefficient Regular Expression Complexity — analogous)
- Files:
- [`packages/server/src/notify_bridge_server/api/template_configs.py:608-613`](../../packages/server/src/notify_bridge_server/api/template_configs.py) — `preview_config` calls `SandboxedEnvironment(autoescape=False).from_string(template_body).render(...)` directly.
- [`packages/server/src/notify_bridge_server/api/slot_helpers.py:72-90`](../../packages/server/src/notify_bridge_server/api/slot_helpers.py) — `render_template_preview` (used by `/preview-raw` for both notification and command templates).
- [`packages/server/src/notify_bridge_server/services/notifier.py:494-499`](../../packages/server/src/notify_bridge_server/services/notifier.py) — `send_test_template_notification`.
- The hardened helper [`packages/core/src/notify_bridge_core/templates/renderer.py:48-108`](../../packages/core/src/notify_bridge_core/templates/renderer.py) (with timeout, length caps, output cap) is **not** used here.
- Scenario: An authenticated admin submits `{% for i in range(10**8) %}x{% endfor %}` to `POST /api/template-configs/preview-raw`. Jinja2 has no built-in timeout. The sandbox blocks attribute access but not CPU. The request blocks the FastAPI event loop's executor thread until the worker oomkills or the client times out. Repeat to DoS the API.
- Remediation: Route every render through a single, hardened helper.
```python
# Use the existing core helper consistently
from notify_bridge_core.templates.renderer import render_template
rendered = render_template(template_str, context) # already has timeout + caps
```
For the strict-undefined two-pass validation in `render_template_preview`, fold the watchdog into the helper itself rather than skipping it.
#### M-2. Login rate limit is per-IP only
- CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts)
- Files: [`packages/server/src/notify_bridge_server/auth/routes.py:140-157`](../../packages/server/src/notify_bridge_server/auth/routes.py).
- Scenario: `@limiter.limit("5/minute")` keyed on `get_remote_address` gives 5 attempts per source IP per minute = ~7,200/day per IP. An attacker rotating across 10 IPs (cheap cloud, residential proxies, even a Tor exit pool) gets 72,000/day. With the 8-character minimum password and no complexity requirement, a 7-char-and-common password is reachable in days, not centuries. There is no per-username lockout, no captcha, no MFA.
- Remediation:
1. Add a per-username sliding-window limiter on top of the per-IP one. Use a second `Limiter` whose `key_func` returns the lower-cased username from the body. Re-check after parsing the body.
2. Add an exponential lockout: after N consecutive failures for a username, require a cooldown (record in a `LoginFailure` table or in-memory TTLCache).
3. Document and recommend deploying behind a reverse proxy that adds CAPTCHA / WAF rate-limiting for login (Cloudflare Turnstile is cheap).
4. Track and log failed logins (auth-event audit trail) with src IP + username + timestamp.
```python
# Sketch — a second limiter that keys by username from the parsed body.
async def _check_username_quota(username: str) -> None:
# In-memory TTLCache: 10 attempts per username per 15 minutes
if _username_attempts[username] >= 10:
raise HTTPException(429, "Too many attempts for this account")
_username_attempts[username] += 1
```
#### M-3. Webhook payload log redactor is keyword-based, misses value-based secrets
- CWE: CWE-532 (Insertion of Sensitive Information into Log File)
- Files: [`packages/server/src/notify_bridge_server/api/webhooks.py:326-358`](../../packages/server/src/notify_bridge_server/api/webhooks.py).
- Scenario: `_redact_sensitive_body` walks the JSON and redacts values whose **keys** contain `token`, `auth`, `key`, `secret`, etc. A webhook provider that ships secrets under an innocent key (e.g. `"oauth_state": "ya29.a0..."`, `"continuation": "ABCDE..."`, `"x_state": "..."`) leaves the secret in the persisted payload log. The log row is admin-readable and exported in backups.
- Remediation: Layer a high-entropy value detector on top of the key matcher (e.g. anything matching `[A-Za-z0-9_\-+/=]{32,}` and high Shannon entropy ≥ 3.5). Lower bound: also redact known prefixes (`ya29.`, `xoxb-`, `ghp_`, `glpat_`, `sk-`, `Bearer `).
#### M-4. Webhook ingestion has no replay protection
- CWE: CWE-294 (Authentication Bypass by Capture-replay)
- Files: [`packages/server/src/notify_bridge_server/api/webhooks.py`](../../packages/server/src/notify_bridge_server/api/webhooks.py) — Gitea/Planka/Generic.
- Scenario: An attacker who once intercepts a signed Gitea push event (network downgrade, log leak from a proxy, exfil from the Gitea side) can replay it indefinitely. The HMAC stays valid; the bridge has no nonce / timestamp window / delivery-ID cache. With a webhook that fires `assets_added` it's just noise. With a webhook that triggers an action (planka card-created → `/api/actions/{id}/execute` chained logic), it could be more.
- Remediation: For Gitea, store the last N `X-Gitea-Delivery` UUIDs per provider and reject duplicates; cap with a partial unique index. For the generic webhook, add an optional `replay_window_seconds` + a timestamp-extracting JSONPath in the provider config. Constant-time string compare.
#### M-5. `bcrypt.checkpw` dummy-hash literal is malformed
- CWE: CWE-208 (Observable Timing Discrepancy) — partial.
- Files: [`packages/server/src/notify_bridge_server/auth/routes.py:147-152`](../../packages/server/src/notify_bridge_server/auth/routes.py).
- Scenario: When the username doesn't exist, the code calls `_verify_password(body.password, "$2b$12$" + "a" * 53)`. That hash is not a real bcrypt hash; `bcrypt.checkpw` raises `ValueError` which `_verify_password` swallows and returns `False`. The exception path is *faster* than a real bcrypt verify (no key schedule), so timing of "user does not exist" differs from "user exists, wrong password" — a maintainer changing the swallow behaviour later could regress this entirely.
- Remediation: Cache one valid dummy bcrypt hash at module load time so the verify path actually runs the KDF.
```python
_DUMMY_BCRYPT_HASH = bcrypt.hashpw(b"x", bcrypt.gensalt()).decode() # module load
...
password_ok = await _verify_password(
body.password,
user.hashed_password if user else _DUMMY_BCRYPT_HASH,
)
```
#### M-6. Setup endpoint relies on `User.id != 0` filter — robust but a single typo breaks it
- CWE: CWE-302 (Authentication Bypass) — defence-in-depth.
- Files: [`packages/server/src/notify_bridge_server/auth/routes.py:97-119`](../../packages/server/src/notify_bridge_server/auth/routes.py).
- Scenario: `POST /api/auth/setup` is gated by "no users with id != 0". The `__system__` sentinel is id=0. If a future migration changes the sentinel id, or the `WHERE` clause is dropped during a refactor, setup re-opens silently and an internet-reachable bridge would let an attacker claim the admin account.
- Remediation: Add a defence-in-depth flag `AppSetting.setup_completed=true` set during the first successful setup, and require it to be unset (in addition to the count check). This bakes the invariant into a single boolean that's easier to audit.
#### M-7. Anonymous Prometheus metrics endpoint leaks operational data
- CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
- Files: [`packages/server/src/notify_bridge_server/api/metrics.py:138-159`](../../packages/server/src/notify_bridge_server/api/metrics.py).
- Notes: This is **documented and gated** by `NOTIFY_BRIDGE_METRICS_ENABLED`, and the comment explicitly says scrapers don't authenticate. Acceptable when the API port is firewalled to the scraper. Surface it here as informational so an operator who exposes the API directly to the internet (e.g. via reverse-proxy without an ACL) doesn't accidentally expose dispatch rates, provider names, queue depths.
- Remediation: keep the env flag, but additionally allow `metrics_basic_auth_user` / `metrics_basic_auth_password` as a soft credential check on the endpoint so a "default enabled, default protected" mode is possible. Document the threat in `OPERATIONS.md` next to the env var.
---
### LOW
#### L-1. CSP allows `'unsafe-inline'` for scripts
- CWE: CWE-1021 (Improper Restriction of Rendered UI Layers or Frames) — adjacent.
- File: [`packages/server/src/notify_bridge_server/main.py:186-201`](../../packages/server/src/notify_bridge_server/main.py).
- Notes: Comment explicitly justifies it — SvelteKit static adapter emits an inline bootstrap. Acceptable, but `'strict-dynamic'` with a per-page nonce (or moving the bootstrap into a hashed external module) eliminates the gap entirely. Track as INFO unless future XSS-injection paths emerge.
#### L-2. CSP `style-src 'unsafe-inline'` allows inline-style XSS payloads
- CWE: CWE-79 (Cross-site Scripting) — defence-in-depth.
- Same file as L-1. Inline styles are not directly executable, but they are a known vector for click-jacking and data-exfil via CSS selectors. Same remediation path: nonce-based CSP.
#### L-3. `frame-ancestors 'none'` but no `X-Frame-Options: DENY` collision (false — it is set)
- INFO only. Both `X-Frame-Options: DENY` and `frame-ancestors 'none'` are set; modern browsers honour CSP, legacy ones honour XFO. Good.
#### L-4. Webhook `_filter_headers` allowlist accepts unknown `X-*` headers
- CWE: CWE-532
- File: [`packages/server/src/notify_bridge_server/api/webhooks.py:361-374`](../../packages/server/src/notify_bridge_server/api/webhooks.py).
- Notes: The filter strips known sensitive headers, then accepts any `X-*`. A custom auth header like `X-Custom-Authentication: <token>` would slip past the substring check if the name doesn't contain `auth`/`token`/`key`/`secret`/etc. Low risk because the well-known providers we support don't ship such headers, but a misconfigured generic webhook will leave a credential in the log row.
- Remediation: invert the policy — explicit allowlist for known-safe `X-*` headers (e.g. `X-Forwarded-For` is also borderline since it can carry PII).
#### L-5. `external_url` setting is not validated against an allow-list
- CWE: CWE-918 (SSRF), CWE-79 (XSS in the rendered Telegram webhook URL).
- File: [`packages/server/src/notify_bridge_server/api/app_settings.py:329-339`](../../packages/server/src/notify_bridge_server/api/app_settings.py) reads, [`packages/server/src/notify_bridge_server/api/telegram_bots.py:247`](../../packages/server/src/notify_bridge_server/api/telegram_bots.py) writes it into the registered Telegram webhook URL.
- Notes: An admin can set `external_url` to anything. The value is used to build the URL passed to Telegram in `setWebhook`. Telegram itself enforces an HTTPS-only allow-list, so the actual risk is bounded. Still — validate scheme + host + that it doesn't include credentials or fragments.
#### L-6. Bot token GET endpoint is intentional but worth auditing
- File: [`packages/server/src/notify_bridge_server/api/telegram_bots.py:148-156`](../../packages/server/src/notify_bridge_server/api/telegram_bots.py).
- Notes: `GET /api/telegram-bots/{bot_id}/token` returns the full Telegram bot token to the owner. Used by the frontend to construct webhook URLs. Limiting to a single short-lived nonce per `register_bot_webhook` flow would be safer than exposing the token directly. Currently INFO; revisit if a multi-user role model lands.
#### L-7. SQLite journal mode + backup snapshot file permissions
- File: [`packages/server/src/notify_bridge_server/database/snapshot.py:60-95`](../../packages/server/src/notify_bridge_server/database/snapshot.py).
- Notes: Snapshots are written via `VACUUM INTO 'path'`. They land in `data_dir/backups/` with default umask permissions. In the Docker image the dir is owned by `appuser` and only that user runs the process, so this is fine. On a host bind-mount, an operator who forgets to lock down `/data` exposes every credential in every snapshot to anyone with shell access. Document this in `OPERATIONS.md`.
#### L-8. No CSRF token on state-changing endpoints
- CWE: CWE-352
- Notes: The API uses `Authorization: Bearer <jwt>` exclusively (no cookies). Browsers don't auto-attach `Authorization` headers cross-origin, so this is **not** classical CSRF-exploitable. Combined with strict CORS (`allow_credentials=True`, explicit origin allowlist, wildcard rejected on startup) and the `Origin`/`Referer` same-host check on the backup endpoints, the practical risk is essentially zero. INFO only.
---
### INFO / NEEDS VERIFICATION
#### N-1. Jinja2 `SandboxedEnvironment` is the standard sandbox — confirm it covers your threat model
- The sandbox blocks `__class__`, `__mro__`, etc., but it is well-known that Jinja2's sandbox is not a security boundary against a determined attacker who can author templates. The threat model here is "templates are admin-authored, so we trust them but use the sandbox as defence-in-depth"; that is reasonable. Document explicitly in `OPERATIONS.md` that anyone with template-edit permission has effective RCE on the worker thread (`{{ foo.__init__.__globals__... }}` style escapes have been published in the past; new ones surface periodically).
- Verification: run `bandit -r packages/` and `safety check` against pinned versions of `jinja2>=3.1`. Latest CVEs against Jinja2 sandbox: track `CVE-2024-34064` and any 2025+ disclosures. As of the review date there is no known unpatched sandbox-escape in `jinja2>=3.1.4`.
#### N-2. `apscheduler<4`
- Notes: The pin `apscheduler>=3.10,<4` keeps the bridge on the 3.x line, which is in maintenance. No known CVEs as of this review. Track when 4.x stabilises and migrate.
#### N-3. `python-multipart>=0.0.9`
- Notes: This package had high-severity bugs prior to 0.0.6. The minimum here is 0.0.9 — good.
#### N-4. No signed-image / SBOM on the container
- Notes: The `release.yml` workflow builds and pushes a multi-tag image but does not sign with cosign or emit an SBOM. For an internet-facing deployment, consider adding `cosign sign` against the image digest, and `syft packages` to emit an SBOM at release time. INFO only.
#### N-5. Frontend dependencies are pinned via caret (`^`) ranges
- Notes: `package.json` uses `^x.y.z`. CI builds `npm ci` from `package-lock.json`, so reproducibility is fine at build time. There is no `npm audit` step in `.gitea/workflows/build.yml`. Add `npm audit --audit-level=high` to the frontend build job.
#### N-6. `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` is a footgun
- File: [`packages/core/src/notify_bridge_core/notifications/ssrf.py:39-52`](../../packages/core/src/notify_bridge_core/notifications/ssrf.py).
- Notes: When set, the SSRF guard becomes a no-op. The warning at boot is the only mitigation. Acceptable for the documented homelab use-case; document that the env flag must NEVER be set on an internet-reachable instance, and consider refusing to enable it when `cors_allowed_origins` resolves to a non-loopback host (defence-in-depth interlock).
#### N-7. Verify the auth flow at the WebSocket boundary
- File: [`packages/core/src/notify_bridge_core/providers/home_assistant/client.py:54-83`](../../packages/core/src/notify_bridge_core/providers/home_assistant/client.py).
- The `_ws_url_from_base` correctly strips userinfo before connecting and `_redact` defangs error messages — verify that `wss://` URLs go through SSRF validation (currently the HA URL is validated by `AnyHttpUrl` at config time but I did not find a call to `avalidate_outbound_url_full` on the HA WS connect path; the resolver would not pin a host the validator never saw).
- Action: confirm by reading `ha_subscription.py` for explicit validation, or add a check that calls `avalidate_outbound_url_full` against the derived `ws_url` (treating `ws`/`wss` like `http`/`https` for the block-range check) before `ws_connect`.
---
## Prioritised Fix List (Top 10)
1. **HIGH H-1** — Add `access_token` to the secret-mask list in `providers._provider_response` and the placeholder-drop list in `providers.update_provider`. Add a regression test that GETs an HA provider and asserts the response does not contain the cleartext token.
2. **HIGH H-2** — Implement column-level encryption for `TelegramBot.token`, `MatrixBot` access tokens, `EmailBot.smtp_password`, and the sensitive keys inside `ServiceProvider.config`. Use Fernet with a key derived from `SECRET_KEY`. Write a one-shot migration.
3. **MEDIUM M-1** — Replace the ad-hoc `SandboxedEnvironment(...).render()` calls in the four preview/test paths with the single hardened `render_template()` helper that already has timeout + size caps.
4. **MEDIUM M-2** — Add per-username login lockout (TTL cache or DB-backed) on top of the per-IP `5/minute`. Log failed login attempts.
5. **MEDIUM M-5** — Replace the malformed dummy bcrypt literal in `login()` with a real bcrypt hash computed once at module load so the timing-equalisation actually runs the KDF.
6. **MEDIUM M-3** — Strengthen `_redact_sensitive_body` with a value-entropy heuristic and well-known token-prefix matching.
7. **MEDIUM M-4** — Add replay protection on Gitea webhooks via the `X-Gitea-Delivery` header (small table + partial unique index).
8. **MEDIUM M-7** — Make the metrics endpoint require either a flag or a Basic Auth credential; document in `OPERATIONS.md` that the API port should not be internet-exposed when metrics are on.
9. **MEDIUM M-6** — Add a defence-in-depth `setup_completed` boolean in `app_setting` and check it in `/api/auth/setup` in addition to the count.
10. **N-5** — Add `npm audit --audit-level=high` to the frontend build job in `.gitea/workflows/build.yml` so dependency CVEs land in CI.
---
## What was confirmed safe (worth keeping)
- JWT design: HS256 with `iss`/`aud`/`exp`/`type`/`sub`/`ver`; refresh/access split; `token_version` revocation on role change, username change, and password change.
- bcrypt with 72-byte length guard; CPU-bound work run in a thread.
- SSRF guard with: scheme allowlist, IPv6-mapped-v4 unwrap, CGNAT block, IDN normalisation, async resolver, `PinnedResolver` to defeat DNS rebinding.
- SQL access goes through SQLModel/SQLAlchemy with bind parameters; the only `f"..."` SQL is in DDL (column adds, index creates, `VACUUM INTO`) using server-controlled identifiers — sampled and clean.
- Sandbox is `SandboxedEnvironment` everywhere a user-controllable template is rendered (six locations checked).
- Frontend `{@html}` is wrapped in `sanitizePreview()` everywhere (`tracking-configs`, `template-configs`, `command-template-configs`).
- Provider config secrets are masked on GET (except H-1).
- `_resolve_backup_file` rejects `..`, NUL, separators, and enforces `relative_to(base)`.
- CORS rejects wildcard with credentials at startup; secret_key default values are rejected with a clear error.
- Docker: non-root user, `read_only: true`, `tmpfs: /tmp`, `no-new-privileges`, `cap_drop: ALL`, resource limits, healthcheck on `/api/ready`.
- Logging: `SecretMaskingFilter` masks Telegram bot tokens, `Authorization`, `x-api-key`, `password`, `secret`, `access_token`, `refresh_token` from formatted messages, exception text, and stack traces.
- Telegram webhook: secret token mandatory, refused on missing config, opaque `webhook_path_id` separate from bot token.
- Inbound generic webhook: refuses `auth_mode="none"` unless an explicit acknowledgment field is set; auto-generates a strong secret if missing for `bearer_token`/`hmac_sha256`.
- Inbound payload size capped at 1 MiB with a streaming check that doesn't trust `Content-Length`.
---
## Methodology
- Manual code review of every authentication, authorization, webhook ingestion, template rendering, secret-handling, and outbound HTTP path under `packages/`.
- Cross-checked CORS / CSP / security headers and rate-limiter configuration in `main.py` + `auth/routes.py`.
- Sampled API routes for ownership enforcement (`get_owned_entity` / `_get_user_provider` / `_get_user_bot`) — all sampled routes apply it; no IDOR found.
- Grepped for `Environment(` / `jinja2.Environment` / `f"..."` SQL / `{@html}` / `subprocess` / `eval` / `os.system` / known-bad patterns.
- Reviewed CI workflows for secret leakage in env blocks and image-signing posture.
- Reviewed Dockerfile + docker-compose for least-privilege and read-only root.
- No dynamic testing performed; static review only. Run `pytest` (already gated in CI) + `bandit -r packages/` + `npm audit` in CI to backstop this review.
+408
View File
@@ -0,0 +1,408 @@
# UI / UX Design Review — Notify Bridge frontend
**Reviewed**: 2026-05-22
**Scope**: SvelteKit frontend at `frontend/`, "Aurora / Glass" aesthetic, en + ru locales.
**Reviewer method**: Read `app.css`, `+layout.svelte`, dashboard, login, setup, providers, targets, users, settings (parent), settings/IdentityCassette, notification-trackers, template-configs, actions, bots, plus shared components (Card, Button, Modal, ConfirmModal, AuthLayout, PageHeader, EmptyState, Loading, Snackbar). Cross-cutting Grep passes for inputs, border-radius, ARIA, sort, hex colors.
---
## Executive summary
- **Aurora design language is real and distinctive.** Newsreader display serif + Geist variable sans + Geist Mono, conic-gradient brand orb, animated radial-gradient aurora background (`body::before` 28s drift), gradient pill chips, glow-pulse dots, and the lavender/orchid/mint/citrus/coral/sky palette together give the product a clear visual identity. This is **not** generic admin-template AI slop — the dashboard hero, signal-stream rows, provider deck, and the `PageHeader` "subpage-hero" pattern all carry intentional character that the user will remember.
- **Consistency is the weakest axis.** Five overlapping card container abstractions (`.hero-card`, `.panel`, `.glass`, `Card.svelte`, settings `.cassette`/`.identity`) re-implement the same frosted-glass recipe with diverging radius (22 / 18 / 14 / 12 px) and padding (1.25/1.4 vs 1.3/1.4 vs 2/2.4 rem). A `--radius: 1rem` token is declared but unused. Pick one card module + one radius scale (e.g. `--radius-card: 22px`, `--radius-input: 12px`, `--radius-pill: 999px`).
- **Forms have not been migrated to Aurora.** ~71 occurrences across 17 files still use the legacy raw class string `border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]` instead of the global `input { ... }` rule already in `app.css` (which uses `--color-input-bg`, `--color-rule-strong`, 0.625rem radius, glow focus ring). Result: rounded-md (6px) fields next to rounded-2xl (22px) cards, solid opaque backgrounds inside frosted-glass cards. Removing the override class would auto-restyle every form to match. **HIGH** priority, mostly mechanical.
- **Hardcoded hex colors leak through.** Snackbar uses `#059669` / `#ef4444` / `#3b82f6` / `#f59e0b` instead of `--color-mint/coral/sky/citrus`. ConfirmModal uses a raw `rgba(239, 68, 68, 0.3)` glow. Actions page uses `#059669` for the enabled dot. All bypass theming — they will look wrong in light theme.
- **Snackbar is invisible to screen readers.** No `role="status"` / `aria-live="polite"` / `aria-live="assertive"` on the toast container. Critical confirmations (saved, deleted, error) are never announced. **HIGH** accessibility fix, one-line.
- **No `aria-current="page"` anywhere in the nav** — active state is conveyed only visually (border-radius bar + glow). Active state has no accessible name.
- **No sortable columns, no multi-select bulk actions, anywhere in the app.** Lists rely entirely on `IconGridSelect` sort widgets (newest / oldest, etc.) and per-row icon buttons. For a notification routing system that may accumulate dozens of trackers / targets / configs, this scales poorly.
- **Localization parity is solid string-for-string** (en.json = ru.json = 1577 lines). Russian renders the same characters but several places (hero title, brand row with provider name, stat-card label/value flex) have no length-guard for the longer Russian translations — visible truncation/wrapping likely.
- **Onboarding is a single screen.** After `/setup` lands you on `/` with `0 providers` and a hero saying "all clear" — the most important first-run moment shows nothing to do. No checklist, no empty-dashboard CTA panel, no tour.
- **Power-user feature standout**: ⌘K SearchPalette is present and wired through the topbar, global provider filter, and reduced-motion media-query support. These three deserve credit and should be more discoverable (no in-app hint they exist).
---
## Findings by area
### 1. Design quality vs generic AI aesthetic
#### F-DESIGN-01 — Aurora identity is strong and self-consistent at the macro level [LOW / commendation]
- **Files**: [`frontend/src/app.css`](frontend/src/app.css), [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte), [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte)
- **State**: Newsreader display serif italic with linear-gradient text-clip is used in hero titles, panel titles, modal titles. Conic brand orb is unique. Aurora drift on body::before is a 28s slow loop that's never busy. The "signal" / "wires" / "on watch" / "pulse" / "stream" / "compose" semantic naming on the dashboard is editorial, not generic admin copy.
- **Verdict**: Keep all of this. Lean *further* into it on the subpages — most list pages currently default back to plain "PageHeader + Card list" without inheriting the dashboard's editorial flavor.
#### F-DESIGN-02 — Italic-serif emphasis loses impact on smaller subpage titles [LOW]
- **Files**: [`frontend/src/lib/components/PageHeader.svelte`](frontend/src/lib/components/PageHeader.svelte) (lines 132147)
- **State**: `subpage-hero__title` is 2.15rem with italic emphasis on a gradient. At that size the gradient italic word is legible but loses the editorial drama it has at the 3rem dashboard hero. Russian translations (`em` words like *«операторы»*) sometimes look cramped because letter-spacing -0.025em is shared with the much larger dashboard hero.
- **Suggestion**: Use a separate letter-spacing scale per font size step, or drop italic emphasis on titles below ~2rem and use color-only emphasis there.
---
### 2. Visual consistency
#### F-CONSIST-01 — Five overlapping card abstractions [HIGH]
- **Files**: [`frontend/src/app.css`](frontend/src/app.css) `.glass`, [`frontend/src/lib/components/Card.svelte`](frontend/src/lib/components/Card.svelte), [`frontend/src/lib/components/PageHeader.svelte`](frontend/src/lib/components/PageHeader.svelte) `.subpage-hero`, [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) `.hero-card` / `.panel` / `.stat-card`, [`frontend/src/routes/settings/IdentityCassette.svelte`](frontend/src/routes/settings/IdentityCassette.svelte) `.identity` + `.glass`
- **State**: Six places re-declare the same recipe: `background: var(--color-glass); backdrop-filter: blur(28px) saturate(160%); border: 1px solid var(--color-border); border-radius: 22px; box-shadow: var(--shadow-card);` followed by an `::after` highlight overlay. Card.svelte even has its own 22px radius next to the global `.glass` 22px radius — they would diverge silently if either gets touched.
- **Suggestion**: Consolidate into one `<GlassPanel>` component (or `.glass-card` utility) with variants `default | hero | panel | cassette` for padding/radius differences. Delete the duplicated `::after` overlays. The pattern is good — it's just *copy-pasted* 5+ times.
#### F-CONSIST-02 — Border-radius drift, no scale [HIGH]
- **Files**: [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte), [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte), [`frontend/src/app.css`](frontend/src/app.css)
- **State**: Radii used: 22, 18, 14, 12, 11, 10, 9, 8, 7, 6, 3, 2 px + 0.3, 0.5, 0.625, 0.85, 1 rem + 9999px. `--radius: 1rem` is declared in the theme but only re-declared — no component reads it.
- **Suggestion**: Define and *use* `--radius-card: 22px; --radius-panel: 18px; --radius-pill: 999px; --radius-input: 12px; --radius-chip: 8px; --radius-tile: 6px;`. Refactor in passes — start with `Card.svelte`, `Button.svelte`, `Modal.svelte`, `ConfirmModal.svelte`.
#### F-CONSIST-03 — Hardcoded hex colors bypass theming [HIGH]
- **Files**:
- [`frontend/src/lib/components/Snackbar.svelte`](frontend/src/lib/components/Snackbar.svelte) lines 2631: `#059669 / #ef4444 / #3b82f6 / #f59e0b`
- [`frontend/src/lib/components/ConfirmModal.svelte`](frontend/src/lib/components/ConfirmModal.svelte) line 70: `box-shadow: 0 0 16px rgba(239, 68, 68, 0.3)`
- [`frontend/src/routes/actions/+page.svelte`](frontend/src/routes/actions/+page.svelte) line 379: `style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"`
- 25 files in `frontend/src/routes/**` contain `#xxx` literals
- **State**: These colors are NOT the Aurora palette — `#059669` is emerald-600, our mint is `#7ee8c4`. In light theme the user sees green-on-green that wasn't intended.
- **Suggestion**: Replace all status hexes with `--color-mint/coral/sky/citrus/orchid`. Add a stylelint rule `color-no-hex` scoped to `src/**/*.svelte` to prevent regression.
#### F-CONSIST-04 — Form input styling not migrated to Aurora [HIGH]
- **Files**: 17 routes, ~71 occurrences. Examples: [`frontend/src/routes/users/+page.svelte`](frontend/src/routes/users/+page.svelte) lines 137, 141, 190, 207; [`frontend/src/routes/providers/+page.svelte`](frontend/src/routes/providers/+page.svelte) lines 303, 309, 323, 333; [`frontend/src/routes/notification-trackers/TrackerForm.svelte`](frontend/src/routes/notification-trackers/TrackerForm.svelte); [`frontend/src/routes/targets/TargetForm.svelte`](frontend/src/routes/targets/TargetForm.svelte).
- **State**: `class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]"` is repeated 71+ times. This overrides the global `input { ... }` rule that *already* uses Aurora glass styling.
- **Suggestion**: Delete the class string in all these places. The global rule kicks in and forms instantly look correct. Cross-check that `Tailwind`'s preflight isn't interfering. Spot-check one page (e.g. `users/+page.svelte`), confirm visually, then mass-delete via Grep/Edit.
#### F-CONSIST-05 — ConfirmModal duplicates Button.svelte logic [MEDIUM]
- **Files**: [`frontend/src/lib/components/ConfirmModal.svelte`](frontend/src/lib/components/ConfirmModal.svelte)
- **State**: Its `.confirm-btn-cancel` and `.confirm-btn-delete` re-implement what `Button variant="secondary"` and `Button variant="danger"` already provide. The danger button even uses raw `rgba(239,68,68,...)` instead of `--color-error-fg`.
- **Suggestion**: `<Button variant="secondary" onclick={oncancel}>{cancel}</Button>` and `<Button variant="danger" onclick={onconfirm}>{confirm}</Button>`. Removes ~35 lines of CSS.
#### F-CONSIST-06 — AuthLayout uses a different glass recipe [MEDIUM]
- **Files**: [`frontend/src/lib/components/AuthLayout.svelte`](frontend/src/lib/components/AuthLayout.svelte) (line 68 `.auth-card`)
- **State**: `border-radius: 1rem`, `padding: 2rem`, `backdrop-filter: blur(8px)` (vs the 28px elsewhere), plus its own auth-bg gradient mesh + 32px-grid background that nothing else in the app uses. Has its own `.auth-input` / `.auth-submit` / `.auth-label` / `.auth-error` design language.
- **State pt 2**: Login/setup ends up looking *more* like generic SaaS than the dashboard does. The brand orb from the sidebar isn't on the login screen — instead a small lavender mdi-lan icon in a square.
- **Suggestion**: Reuse the conic brand orb. Use the same glass recipe (28px blur, 22px radius) for `.auth-card`. Either drop the dot-grid `.auth-grid` (it reads as a generic "futuristic SaaS" template) or use it as a deliberate flair on the dashboard hero too.
---
### 3. Information hierarchy
#### F-HIER-01 — Stat cards do triple duty (KPI + nav link + filter context) without ranking [MEDIUM]
- **Files**: [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) lines 571645
- **State**: All four stat cards have the same visual weight, same accent intensity (`STAT_ACCENTS[idx]`), and rotate accents by index. When the global provider filter is active the first stat card morphs into a "literal value" card showing provider name (1rem font, very different visual). The accent rotation creates a rainbow row that doesn't carry meaning — events `total` has no semantic reason to be orchid vs. providers being lavender.
- **Suggestion**: Tie accent color to entity type (providers=primary, trackers=mint, targets=sky, throughput=citrus) so the same accent recurs throughout the app for the same concept. Keep the morph behavior but design a distinct "filtered context" stat-card variant — a smaller, narrower chip — so it doesn't compete visually.
#### F-HIER-02 — Hero title and meter compete for attention at desktop width [LOW]
- **Files**: [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) lines 10471068, 10781086
- **State**: Both the `.hero-title` and `.hero-meter-value` are 3rem 500-weight in two different fonts. Side-by-side they create two focal points.
- **Suggestion**: Shrink `.hero-meter-value` to 2.4rem and use it as a *secondary* read; let the editorial title be the single dominant element.
#### F-HIER-03 — Pulse chart panel rarely meaningful on first launch [LOW]
- **Files**: [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) lines 909927
- **State**: On a fresh install the chart is an empty 0-events grid taking 250-400px vertical space. No empty-state copy inside `EventChart`.
- **Suggestion**: When `chartDays` has all-zero values, replace with a small "No events recorded in the last 30 days — once a tracker fires, the pulse will appear here" inline empty state.
---
### 4. Navigation & wayfinding
#### F-NAV-01 — No `aria-current="page"` on active nav links [HIGH a11y]
- **Files**: [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) lines 498533, 591597, 632658
- **State**: Active state is conveyed via `.active` class + a gradient left-bar div. Screen readers cannot announce it. Grep for `aria-current` across the whole frontend: zero matches.
- **Suggestion**: Add `aria-current={isActive(child.href) ? 'page' : undefined}` to every nav `<a>`.
#### F-NAV-02 — No breadcrumb on subpages [MEDIUM]
- **Files**: [`frontend/src/lib/components/PageHeader.svelte`](frontend/src/lib/components/PageHeader.svelte)
- **State**: The `crumb` prop only renders a single mono-uppercase tag (e.g. "ROUTING · AUTOMATION") — it's decorative, not navigational. There's no actual breadcrumb chain. For `/template-configs`, `/command-template-configs`, `/tracking-configs`, `/command-configs`, etc., a user landing via deep link has no parent-link to return to.
- **Suggestion**: Make the crumb a real breadcrumb (≤3 levels: `Notifications → Templates` or `Commands → Configs`). Render the prior level as a clickable `<a>`.
#### F-NAV-03 — Deep linking via `?type=<targetType>` and `?tab=<botType>` doesn't update page title [LOW]
- **State**: `/targets?type=email` and `/bots?tab=matrix` change the active sidebar item but the `<PageHeader>` title for those pages is generic ("Targets" / "Bots").
- **Suggestion**: When `activeType` is set, derive the title from it: "Email targets" / "Matrix bots". Improves browser tab titles and the in-page title.
#### F-NAV-04 — Collapsed sidebar tooltip wraps for long Russian translations [LOW]
- **State**: Tooltips for collapsed sidebar nav items use the browser-native `title=` attribute, which gives no glass-style chip. They will use the OS tooltip styling, which clashes with the Aurora aesthetic and clips long ru labels.
- **Suggestion**: Build a small custom tooltip component (or use existing portal helper) for collapsed-sidebar nav. Keep `title` as fallback for `prefers-reduced-motion` users.
---
### 5. Form UX
#### F-FORM-01 — No inline field-level validation, only post-submit error banners [MEDIUM]
- **Files**: [`frontend/src/routes/providers/+page.svelte`](frontend/src/routes/providers/+page.svelte), [`frontend/src/routes/users/+page.svelte`](frontend/src/routes/users/+page.svelte), [`frontend/src/routes/targets/TargetForm.svelte`](frontend/src/routes/targets/TargetForm.svelte)
- **State**: Forms rely on HTML5 `required` / `minlength` browser validation plus a single `ErrorBanner` shown after submit failure. Native browser validation tooltips are pale and don't match Aurora.
- **Suggestion**: Add a per-field `<FieldError>` slot below labels for inline validation (URL syntax, email format, port range). The settings page already has a nice pattern (`url-field-valid` class on `IdentityCassette`) — generalize it.
#### F-FORM-02 — Save feedback inconsistent across pages [MEDIUM]
- **Files**: Settings uses a sticky `SaveBar` with dirty tracking ([`frontend/src/routes/settings/+page.svelte`](frontend/src/routes/settings/+page.svelte) lines 7784, 208214). Most other forms have inline Save buttons inside the card. Some show snackbar success ("snack.userCreated"), some don't.
- **Suggestion**: Standardize: (a) inline "Save" inside the card *plus* (b) snackbar success message *plus* (c) optional sticky SaveBar for multi-field admin forms. Document the pattern in `.claude/docs/frontend-architecture.md`.
#### F-FORM-03 — Forms auto-name from descriptor but offer no way to unlock it back to auto-name [LOW]
- **Files**: [`frontend/src/routes/providers/+page.svelte`](frontend/src/routes/providers/+page.svelte) lines 136141 + 303; [`frontend/src/routes/actions/+page.svelte`](frontend/src/routes/actions/+page.svelte) lines 5056
- **State**: Once user types in the Name field, `nameManuallyEdited` becomes true and the auto-fill stops permanently — no way to ask "go back to default name".
- **Suggestion**: Add a tiny "↺ reset" link next to the name input when `nameManuallyEdited && form.name !== descriptor.defaultName`.
#### F-FORM-04 — No optimistic UI; rows disappear / appear only after server roundtrip [LOW]
- **State**: After delete/create, pages refetch via `cache.fetch(true)`. Visible 200-400ms blank state.
- **Suggestion**: Optimistic insert/remove in the cache stores, with snackbar undo for destructive ops.
#### F-FORM-05 — Login form omits `autofocus` on username [LOW]
- **Files**: [`frontend/src/routes/login/+page.svelte`](frontend/src/routes/login/+page.svelte) line 99
- **Suggestion**: Add `autofocus` to the username input. Saves one keystroke on every login.
---
### 6. Modals & overlays
#### F-MODAL-01 — Modal.svelte is well-built [LOW / commendation]
- **Files**: [`frontend/src/lib/components/Modal.svelte`](frontend/src/lib/components/Modal.svelte)
- **State**: Portal mount, focus trap, focus restoration, Escape, Tab cycling, `aria-modal="true"`, `aria-labelledby`, body scroll containment via `overscroll-behavior: contain`, transition (250ms in/out), 80vh max-height. This is the strongest single component in the codebase.
- **Verdict**: Reuse as the foundation for every overlay. Currently `BlockedByModal`, `EventDetailModal`, `SharedLinkModal`, `ConfirmModal` all do — good.
#### F-MODAL-02 — Modal backdrop has `role="button"` [LOW]
- **Files**: [`frontend/src/lib/components/Modal.svelte`](frontend/src/lib/components/Modal.svelte) line 96
- **State**: The backdrop is a `<div>` with `role="button"`, `tabindex="-1"`, and an onclick to close. That's a common pattern to silence Svelte's a11y warnings, but a screen reader announces "Close, button" twice (once for backdrop, once for the explicit X button).
- **Suggestion**: Drop `role="button"` and `aria-label` from the backdrop; the explicit Close button is enough. Or use `<button class="modal-backdrop">` instead of a div.
#### F-MODAL-03 — Modal panel uses solid `#131520` instead of glass [LOW]
- **Files**: [`frontend/src/lib/components/Modal.svelte`](frontend/src/lib/components/Modal.svelte) lines 150151
- **State**: `--modal-solid-bg: #131520;` is a deliberate choice (probably for readability) but it breaks visual consistency with the rest of the app. The Aurora drift behind it is invisible.
- **Suggestion**: Use `var(--color-glass-elev)` over the blurred backdrop. Or, if the solid choice was deliberate, document why so the next developer doesn't "fix" it.
#### F-MODAL-04 — Confirm-modal "delete" hover uses raw rgba [MEDIUM]
- **Files**: [`frontend/src/lib/components/ConfirmModal.svelte`](frontend/src/lib/components/ConfirmModal.svelte) line 70
- **State**: `box-shadow: 0 0 16px rgba(239, 68, 68, 0.3);` — not themed.
- **Suggestion**: Use `box-shadow: 0 0 16px color-mix(in srgb, var(--color-coral) 40%, transparent);`.
---
### 7. Empty / loading / error states
#### F-STATE-01 — `Loading.svelte` is a single shimmer pattern [MEDIUM]
- **Files**: [`frontend/src/lib/components/Loading.svelte`](frontend/src/lib/components/Loading.svelte)
- **State**: Three or four 4rem shimmer bars. Used as `<Loading />` on virtually every page including hero pages. Doesn't match the actual layout the user will see — looks like a row list even on settings.
- **Suggestion**: Add layout-aware variants: `<Loading shape="hero" />`, `<Loading shape="grid" cols={4} />`, `<Loading shape="list" rows={5} />`. Reduces layout shift on first paint.
#### F-STATE-02 — `EmptyState.svelte` is plain and undifferentiated [MEDIUM]
- **Files**: [`frontend/src/lib/components/EmptyState.svelte`](frontend/src/lib/components/EmptyState.svelte)
- **State**: 10-line component: dimmed icon + message. No CTA, no illustration, no flavor. The dashboard's inline `.empty-state` (lines 13001319 of `+page.svelte`) is richer (has a CTA link) but isn't reused.
- **Suggestion**: Extend `EmptyState` to accept a `cta` slot and a `tone` (with subtle gradient blob behind the icon). On `/providers` empty: "No providers yet — connect Immich, Nextcloud, or Home Assistant to start tracking events" with an "+ Add provider" CTA.
#### F-STATE-03 — Many list pages have no error-recovery action [MEDIUM]
- **Files**: Throughout — most pages have a `loadError` state that renders `<Card><ErrorBanner /></Card>` but no "Retry" button.
- **Suggestion**: `ErrorBanner` should accept an `onRetry` prop and surface a retry button. Standardize across pages.
#### F-STATE-04 — `EventChart` no empty state [LOW]
- See F-HIER-03.
---
### 8. Accessibility
#### F-A11Y-01 — Snackbar has no aria-live [HIGH]
- **Files**: [`frontend/src/lib/components/Snackbar.svelte`](frontend/src/lib/components/Snackbar.svelte) lines 3563
- **State**: Snack container is a plain `<div use:portal>`. Success / error toasts never reach screen readers. Three other files have proper aria-live; this critical one doesn't.
- **Fix**: `<div use:portal class="snackbar-container" role="region" aria-live="polite" aria-label={t('snackbar.region')}>`. Use `aria-live="assertive"` for `snack.type === 'error'`.
#### F-A11Y-02 — No `aria-current="page"` on nav links [HIGH]
- See F-NAV-01.
#### F-A11Y-03 — Custom focus outlines partially overridden [MEDIUM]
- **Files**: [`frontend/src/app.css`](frontend/src/app.css) lines 237241 (global `button:focus-visible` outline 2px primary + offset 2px), [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) line 894 (`.nav-link { border-radius: 12px !important }`), [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) lines 13511354 (`.signal-row--clickable:focus-visible { outline-offset: -2px }`).
- **State**: Inverted offset `-2px` makes the focus ring sit *inside* the row, which against the glass-strong hover-bg ends up nearly invisible at certain accent positions.
- **Suggestion**: Use `outline-offset: 2px` consistently with a `box-shadow: 0 0 0 2px var(--color-glass)` ringer if needed for contrast.
#### F-A11Y-04 — `prefers-reduced-motion` is honored — commendation [LOW]
- **Files**: [`frontend/src/app.css`](frontend/src/app.css) lines 484507, [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) lines 837840
- **State**: Aurora drift, brand-version pulse, stagger entrances, signal-row hover transitions, paginator transitions all gated. Smooth scroll override too. Solid implementation.
#### F-A11Y-05 — Color contrast risk on glass surfaces [MEDIUM]
- **State**: `--color-muted-foreground: #b6b2d4` on `--color-glass: rgba(255,255,255,0.04)` over the aurora gradient. In the brightest hot-spot of the aurora background (where the `#b8a7ff` lavender peaks), `#b6b2d4` may fail WCAG AA (4.5:1 for body text). Hasn't been measured.
- **Suggestion**: Run a contrast pass with `--color-muted-foreground` against the brightest part of the aurora background. Likely need to bump it to ~`#cfcae8` for dark theme.
#### F-A11Y-06 — Toggle switch has no label association [LOW]
- **Files**: [`frontend/src/app.css`](frontend/src/app.css) lines 513556
- **State**: `.toggle-switch` wraps an `<input type="checkbox">` and a visual `.toggle-track` `<span>`. There's no visible label text or `aria-label` requirement in the global utility. Callers may forget to pass one.
- **Suggestion**: Lift into a `<Toggle>` component requiring a `label` prop.
---
### 9. Responsive design
#### F-RESP-01 — Sidebar collapse breakpoint is fine; mobile bottom nav covers gracefully [LOW / commendation]
- **Files**: [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) lines 589668, 11361168
- **State**: Below 767px the desktop sidebar hides and mobile bottom-nav appears with primary 4 keys + search + more. Mobile "More" panel mirrors the full desktop tree. Solid.
#### F-RESP-02 — Hero meter wraps awkwardly between 720880px [LOW]
- **Files**: [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) lines 11191130
- **State**: Below 880px the hero collapses to one column, but the meter row pills wrap to a third row on Russian translations of "providers/targets/armed".
- **Suggestion**: Add an intermediate breakpoint (`max-width: 1024px`) where pill labels switch from `"5 providers"` to a tooltip-only count.
#### F-RESP-03 — Stat-card grid drops to 1 column at sm: [MEDIUM]
- **Files**: [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte) line 590 `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4`
- **State**: Between 6401024px stat cards are 2-wide. At tablet sizes the cards become huge and dilute the dashboard density.
- **Suggestion**: Cap stat-card max-width at ~300px or switch to `auto-fit, minmax(200px, 1fr)` so they don't grow uncontrollably.
#### F-RESP-04 — List rows don't gracefully truncate webhook URLs on mobile [LOW]
- **Files**: [`frontend/src/routes/providers/+page.svelte`](frontend/src/routes/providers/+page.svelte) lines 392410
- **State**: Secondary text line shows full webhook URL with `break-all` which on very narrow viewports gives a 4-line wrap.
- **Suggestion**: Use the `shortenUrl()` helper (already defined for the meta-tile path) on the narrow-screen secondary line too.
---
### 10. Onboarding
#### F-ONBOARD-01 — Setup → empty dashboard with no guidance [HIGH]
- **Files**: [`frontend/src/routes/setup/+page.svelte`](frontend/src/routes/setup/+page.svelte), [`frontend/src/routes/+page.svelte`](frontend/src/routes/+page.svelte)
- **State**: After `/setup` the user lands on `/` with 0 providers, hero says *"all clear"* (literally "Nothing to do"). Wasted first impression.
- **Suggestion**: First-run detection (`providersCache.items.length === 0 && targetsCache.items.length === 0`) replaces the dashboard hero with a 3-4 step "Getting started" checklist: (1) Add a provider · (2) Connect a bot · (3) Create a target · (4) Wire your first tracker. Each step is a CTA card. Persist completion to localStorage so it disappears once finished.
#### F-ONBOARD-02 — No in-app discovery of ⌘K palette [MEDIUM]
- **Files**: [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) lines 678682
- **State**: Topbar shows `⌘K` / `Ctrl K` chip but only that. No "Press ⌘K to jump to any page" hint anywhere.
- **Suggestion**: First-visit toast: "Tip: Press ⌘K from anywhere to search providers, trackers, and pages". Dismissible.
#### F-ONBOARD-03 — Login screen has no help / forgot-password / docs link [LOW]
- **Files**: [`frontend/src/routes/login/+page.svelte`](frontend/src/routes/login/+page.svelte)
- **State**: Plain username + password. For self-hosted users who lost the admin password, there's no link to the recovery docs.
- **Suggestion**: Small "Need help?" link to docs (the `/docs` route exists).
---
### 11. Microcopy
#### F-COPY-01 — Dashboard hero copy is editorial — commendation [LOW]
- "Live · throughput 24h · armed · providers" reads more like a control-room dashboard than CRUD admin. Keep doing this on the rest of the app.
#### F-COPY-02 — Many subpages use literal entity-name copy [MEDIUM]
- E.g. "Add provider" / "Add target" / "Add tracker" / "Add user". Editorial would be "Connect a provider" / "Define a target" / "Wire a tracker" / "Invite a user". Lean into verbs that match the dashboard's "wires / signals / on watch" vocabulary.
#### F-COPY-03 — Russian translations match en line-count but no length QA visible [LOW]
- File sizes match exactly (1577 lines each). That's just structural parity, not visual parity. Russian tends to be 20-30% longer for the same concept; flagged places likely have layout issues (hero title em, stat-card values, sidebar nav labels).
- **Suggestion**: Set up a Playwright snapshot test that switches locale=ru and screenshots dashboard + a representative list page to catch overflow visually.
---
### 12. Localization parity
#### F-LOCALE-01 — "Notify Bridge" wordmark stays in English [LOW / correct]
- Brand. Don't translate.
#### F-LOCALE-02 — Provider type label not localized in list rows [LOW]
- **Files**: [`frontend/src/routes/providers/+page.svelte`](frontend/src/routes/providers/+page.svelte) line 391
- **State**: Type pill shows raw `provider.type` value (e.g. "immich", "nextcloud") — not localized.
- **Suggestion**: Use `getDescriptor(type).defaultName` or `t(\`providers.type${PascalName}\`)` which exists per project conventions.
#### F-LOCALE-03 — Mixed Cyrillic glitches in source [LOW]
- **Files**: [`frontend/src/routes/login/+page.svelte`](frontend/src/routes/login/+page.svelte) line 42 (`—` instead of em-dash in a comment), [`frontend/src/routes/users/+page.svelte`](frontend/src/routes/users/+page.svelte) line 166 (`В·` instead of `·`)
- **State**: Encoding-corrupt characters in source comments and one user-facing dot. Pre-existing — files were probably edited with the wrong encoding at some point.
- **Suggestion**: Grep `вЂ` / `В·` across the repo and fix. Add a pre-commit hook that fails on non-UTF8 chars in `.svelte` / `.ts` / `.json`.
---
### 13. Power-user features
#### F-POWER-01 — No sortable columns anywhere [MEDIUM]
- Confirmed by Grep: no `aria-sort` / `sortable` / `onSort` in the codebase. Lists are sorted by `IconGridSelect` widget (newest / oldest / name).
- **Suggestion**: For long lists (trackers, targets), add column-header sort affordance. Even minimal: clicking the "Name" or "Provider" header re-sorts. Use cache state so sort persists across nav.
#### F-POWER-02 — No multi-select bulk actions [MEDIUM]
- Grep for `bulkAction` / `selectAll`: only the locale files contain those strings (likely as i18n keys that are never used). No checkbox UI.
- **Suggestion**: Add a checkbox column on `targets`, `notification-trackers`, `command-trackers`, `actions` pages. Bulk-enable / bulk-delete are the obvious ones.
#### F-POWER-03 — ⌘K palette is the strongest power feature, under-promoted [MEDIUM]
- See F-ONBOARD-02.
#### F-POWER-04 — Sidebar group expand/collapse is persisted but no "expand all / collapse all" [LOW]
- **Files**: [`frontend/src/routes/+layout.svelte`](frontend/src/routes/+layout.svelte) lines 263269
- **Suggestion**: Add a right-click menu on a group header, or a tiny "collapse all" icon at the bottom of the nav rail.
#### F-POWER-05 — No keyboard shortcuts beyond ⌘K [LOW]
- **Suggestion**: `n` for new, `g + p` for "go providers", `g + t` for trackers, `?` to show shortcut sheet. Document in the palette.
---
## Production polish checklist (top 15, prioritized)
1. **[HIGH]** Add `role="status" aria-live="polite"` to Snackbar container; `assertive` for error toasts. (F-A11Y-01) — one-line fix.
2. **[HIGH]** Add `aria-current="page"` to every nav link in `+layout.svelte`. (F-NAV-01, F-A11Y-02)
3. **[HIGH]** Mass-replace the legacy form-input class (`border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]`) with nothing — let the global `input { ... }` style win. 17 files, ~71 occurrences. (F-CONSIST-04)
4. **[HIGH]** Replace hardcoded hex colors (`#059669`, `#ef4444`, `#3b82f6`, `#f59e0b`, `rgba(239,68,68,...)`) with Aurora palette tokens in `Snackbar.svelte`, `ConfirmModal.svelte`, `actions/+page.svelte`, and any remaining sites. (F-CONSIST-03)
5. **[HIGH]** First-run onboarding: when `providersCache.items.length === 0`, replace dashboard hero with a 4-step "Getting started" checklist. (F-ONBOARD-01)
6. **[HIGH]** Consolidate the 5 glass-card abstractions into a single `<GlassPanel variant=...>` component; delete redundant `::after` overlays. (F-CONSIST-01)
7. **[HIGH]** Introduce a radius scale (`--radius-card / panel / pill / input / chip / tile`) and refactor `Card.svelte`, `Button.svelte`, `Modal.svelte`, `ConfirmModal.svelte` to use it. (F-CONSIST-02)
8. **[MEDIUM]** Rewrite `ConfirmModal.svelte` to use `<Button variant="secondary">` and `<Button variant="danger">` instead of its own buttons. (F-CONSIST-05)
9. **[MEDIUM]** Add layout-aware `<Loading shape="hero|grid|list">` variants to reduce first-paint layout shift. (F-STATE-01)
10. **[MEDIUM]** Extend `<EmptyState>` with `cta` slot and provider-/tracker-/target-specific copy + a contextual CTA. (F-STATE-02)
11. **[MEDIUM]** Visual length-QA pass for Russian — at least dashboard hero, providers list, settings hero, stat-cards. Playwright screenshot test. (F-COPY-03, F-LOCALE-02)
12. **[MEDIUM]** Implement column-header sort on `notification-trackers`, `targets`, `actions`. Persist in cache state. (F-POWER-01)
13. **[MEDIUM]** Add multi-select bulk actions (enable/disable, delete) to `targets`, `notification-trackers`, `command-trackers`. (F-POWER-02)
14. **[MEDIUM]** Audit contrast: `--color-muted-foreground` over brightest aurora peak; likely bump dark-theme value from `#b6b2d4` to ~`#cfcae8`. (F-A11Y-05)
15. **[MEDIUM]** Replace inline browser-native `title=` tooltips on the collapsed sidebar with a custom Aurora-styled tooltip (using the existing portal helper). (F-NAV-04)
### Quick wins (bonus, under an hour each)
- Add `autofocus` to the username input on `/login`. (F-FORM-05)
- Fix `вЂ"` / `В·` Cyrillic encoding glitches in `login/+page.svelte` and `users/+page.svelte`. (F-LOCALE-03)
- Drop `role="button"` from Modal backdrop. (F-MODAL-02)
- Replace `provider.type` raw label in provider list rows with localized descriptor name. (F-LOCALE-02)
- Add inline empty-state copy to `EventChart` when all `chartDays` values are 0. (F-HIER-03)
---
## What's working — keep doing it
- The conic-gradient brand orb, animated aurora background, Newsreader italic emphasis, gradient pill chips, glow-pulse dots — distinctive identity.
- `Modal.svelte` (focus trap, restore, portal, escape, scroll containment).
- `prefers-reduced-motion` honored across every animation surface.
- Global ⌘K search palette, global provider filter, persisted sidebar state, persisted nav-group expansion.
- Editorial copy on dashboard (`signal stream`, `on watch`, `pulse`, `wires`, `compose`).
- Snackbar with detail-toggle expansion for error context.
- Mobile "More" panel that mirrors the full desktop nav tree.
- 6-file template-variable sync rule honored by project conventions.
- `i18n` parity at 1577 lines for both locales.
End of review.
+27
View File
@@ -0,0 +1,27 @@
---
name: Debug Issue
description: Systematically debug issues using graph-powered code navigation
---
## Debug Issue
Use the knowledge graph to systematically trace and debug issues.
### Steps
1. Use `semantic_search_nodes` to find code related to the issue.
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
3. Use `get_flow` to see full execution paths through suspected areas.
4. Run `detect_changes` to check if recent changes caused the issue.
5. Use `get_impact_radius` on suspected files to see what else is affected.
### Tips
- Check both callers and callees to understand the full context.
- Look at affected flows to find the entry point that triggers the bug.
- Recent changes are the most common source of new issues.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Explore Codebase
description: Navigate and understand codebase structure using the knowledge graph
---
## Explore Codebase
Use the code-review-graph MCP tools to explore and understand the codebase.
### Steps
1. Run `list_graph_stats` to see overall codebase metrics.
2. Run `get_architecture_overview` for high-level community structure.
3. Use `list_communities` to find major modules, then `get_community` for details.
4. Use `semantic_search_nodes` to find specific functions or classes.
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
6. Use `list_flows` and `get_flow` to understand execution paths.
### Tips
- Start broad (stats, architecture) then narrow down to specific areas.
- Use `children_of` on a file to see all its functions and classes.
- Use `find_large_functions` to identify complex code.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Refactor Safely
description: Plan and execute safe refactoring using dependency analysis
---
## Refactor Safely
Use the knowledge graph to plan and execute refactoring with confidence.
### Steps
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
5. After changes, run `detect_changes` to verify the refactoring impact.
### Safety Checks
- Always preview before applying (rename mode gives you an edit list).
- Check `get_impact_radius` before major refactors.
- Use `get_affected_flows` to ensure no critical paths are broken.
- Run `find_large_functions` to identify decomposition targets.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+29
View File
@@ -0,0 +1,29 @@
---
name: Review Changes
description: Perform a structured code review using change detection and impact
---
## Review Changes
Perform a thorough, risk-aware code review using the knowledge graph.
### Steps
1. Run `detect_changes` to get risk-scored change analysis.
2. Run `get_affected_flows` to find impacted execution paths.
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
4. Run `get_impact_radius` to understand the blast radius.
5. For any untested changes, suggest specific test cases.
### Output Format
Provide findings grouped by risk level (high/medium/low) with:
- What changed and why it matters
- Test coverage status
- Suggested improvements
- Overall merge recommendation
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+3 -3
View File
@@ -1,8 +1,8 @@
{
"last_commit": "a31b1cba2a41229f6f6af9701477d24d15efbe9a",
"last_sync": "2026-04-21T00:00:00Z",
"last_commit": "04fe8124fcc3f783038b9aaac393b6c62c68e22a",
"last_sync": "2026-05-16T20:04:00Z",
"tracked_files": {
"gitea-python-ci-cd.md": "sha256:61968058ec30cac954a3b7f9bde2a7db620618482d34e17568d432f680a3b333",
"gitea-python-ci-cd.md": "sha256:9f1f57e1b0d909143e20cb3f21ac9c4d75b45f2992ec002645540f94c4920851",
"gitea-release-workflow.md": "sha256:5eb64789fca062b2138ca7661b942c9fc9c304f63326844ff6f6724e7e05b08c"
}
}
+48 -2
View File
@@ -29,16 +29,62 @@ jobs:
- name: Svelte check
run: |
cd frontend
npm run check || echo "::warning::svelte-check reported warnings"
npm run check
- name: Build
run: |
cd frontend
npm run build
test-backend:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Editable installs of packages/core + packages/server are extremely slow
# on the hosted runner — measured 4-6x slower than building wheels first
# because hatchling's editable hook re-resolves on every collection. We
# build wheels once into an isolated venv, then install them (and only
# the test deps). The venv isolation also prevents broken-wheel installs
# from leaking dist-info across runs on the persistent Gitea runner
# (pip can't uninstall a wheel that landed without a RECORD file). The
# wheels themselves are NOT cached because their hashes depend on every
# file under packages/ — invalidates on basically every PR. Pip's HTTP
# cache for the test deps is enough.
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
# Pin aiohttp <3.14: aioresponses 0.7.8 (latest) doesn't pass the
# stream_writer kwarg that aiohttp 3.14 made required on ClientResponse,
# breaking every aioresponses-mocked test. Drop once aioresponses ships
# an aiohttp-3.14-compatible release.
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses 'aiohttp<3.14' prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
build-image:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
needs: [test-frontend]
needs: [test-frontend, test-backend]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+40
View File
@@ -10,7 +10,47 @@ env:
IMAGE_NAME: alexei.dolgolyov/notify-bridge
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Wheel-first strategy in an isolated venv — editable install is too slow,
# and a plain pip install into the toolcache Python leaks state across
# runs on the persistent Gitea runner (previous broken wheel installs
# leave dist-info dirs that pip can't uninstall: "uninstall-no-record-file").
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
# Pin aiohttp <3.14: aioresponses 0.7.8 (latest) doesn't pass the
# stream_writer kwarg that aiohttp 3.14 made required on ClientResponse,
# breaking every aioresponses-mocked test. Drop once aioresponses ships
# an aiohttp-3.14-compatible release.
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses 'aiohttp<3.14' prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
release:
needs: [test-backend]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
+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"
}
}
}
+65
View File
@@ -0,0 +1,65 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# --- Active settings (maximum capability) ---
# Semantic embeddings on, call-graph + BM25 on, and auto-refresh the index when
# stale so queries never run against an out-of-date graph.
semantic = true
auto_update = true
call_graph = true
bm25 = true
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
# semantic = false
# Automatically run `vex update` before search if the index is stale
# auto_update = false
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
# Cache directory override. Defaults to the platform cache location.
# macOS: ~/Library/Caches/vex
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
# cache_dir = "./.vex/cache"
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
# the cache should travel with the project (e.g. on a moved or renamed
# directory). vex writes a `.gitignore` inside it so contents are not
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
# local_cache = false
# Thread count for parallel indexing (index/update/watch).
# * unset — 80% of available cores, rounded up (default, leaves headroom)
# * 0 — use all cores (explicit opt-in to max throughput)
# * N — exactly N workers
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
# jobs = 4
# Build the persistent call-graph section. Disabling falls back to live-scan
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
# time on large monorepos). The opt-out is persisted in the manifest so
# `vex update` does not silently re-add the section.
# Per-invocation override: `vex index --no-call-graph`.
# call_graph = true
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
# only structural (+ semantic). Same persistence rules as `call_graph`.
# Per-invocation override: `vex index --no-bm25`.
# bm25 = true
+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.
+394
View File
@@ -0,0 +1,394 @@
# Operations Guide
This document covers running, monitoring, and recovering Notify Bridge in
production. The intended audience is the operator on call when the
notifications stop firing or when a release upgrade goes sideways.
For developer-focused docs (architecture, conventions, project layout) see
`CLAUDE.md` and the `.claude/docs/` directory.
## Deployment overview
Notify Bridge ships as a single Docker image. All state lives in a single
data directory mounted at `/data`.
### Required environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_SECRET_KEY` | _(none)_ | **Required.** 32+ random bytes. The server refuses to boot with the default placeholder or any of the known dev literals. |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | `http://localhost:5175` | Comma-separated list. `*` is rejected because credentials are enabled. |
| `NOTIFY_BRIDGE_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. |
### Useful environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_DATA_DIR` | `/data` | Where the SQLite DB, snapshots, and backups live. |
| `NOTIFY_BRIDGE_DATABASE_URL` | _(derived from data_dir)_ | Override only if you want a non-default DB path. |
| `NOTIFY_BRIDGE_DEBUG` | `false` | Verbose logging + SQL echo. Do not enable in production. |
| `NOTIFY_BRIDGE_LOG_FORMAT` | `text` | Set to `json` for one JSON object per line — pipe to a log aggregator. |
| `NOTIFY_BRIDGE_LOG_LEVEL` | `INFO` | Root logger level. |
| `NOTIFY_BRIDGE_LOG_LEVELS` | _(empty)_ | Per-module overrides, e.g. `sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG`. |
| `NOTIFY_BRIDGE_EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history kept by the daily cleanup job. `0` disables retention. |
| `NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots retained. `0` disables snapshotting. |
| `NOTIFY_BRIDGE_METRICS_ENABLED` | `true` | Expose `/api/metrics` for Prometheus. Set to `false` if the API port crosses a trust boundary. |
| `NOTIFY_BRIDGE_GRACEFUL_SHUTDOWN_SECONDS` | `60` | SIGTERM grace period before in-flight requests are killed. |
| `NOTIFY_BRIDGE_SUPERVISED` | _(auto)_ | Force the supervised flag for `apply-restart`. Use `true` when running under systemd/PM2 outside Docker. |
### Data directory layout
```
/data/
notify_bridge.db # main SQLite DB (WAL mode)
notify_bridge.db-wal # SQLite write-ahead log
notify_bridge.db-shm # SQLite shared memory file
backups/
pre-migrate-*.db # automatic pre-upgrade snapshots
backup-*.json # scheduled / manual config backups
snapshots/ # legacy alias retained for older deployments
pending_restore.json # staged restore (consumed at next boot)
applied_restores/ # archive of applied restore payloads
```
Always mount `/data` on a persistent volume. The WAL files MUST live on the
same filesystem as the main DB — never split them across mounts.
### Docker example
See `docker-compose.yml` at the repo root for the canonical reference. The
container runs read-only with `tmpfs` for `/tmp`, drops all capabilities,
and limits memory/CPU. The healthcheck targets `/api/ready` (deep) — see
the next section.
## Healthchecks
Two endpoints, used for different probe types.
### `GET /api/health` — liveness, shallow
Returns `200 OK` once the ASGI app has started. Does not touch the DB or
the scheduler. Use this for liveness probes that should only restart the
process if it stops responding entirely.
```json
{"status": "ok", "version": "0.8.0"}
```
### `GET /api/ready` — readiness, deep
Verifies that each critical dependency is reachable:
* **db** — `SELECT 1` against the SQLAlchemy engine, 2-second timeout.
* **scheduler** — APScheduler `running` flag.
* **ha** — Home Assistant subscription supervisor task. Reported as
`na` when no HA providers are configured, `ok` when at least one
supervisor is alive, `degraded` otherwise. **Informational only**
HA degradation does not flip readiness off.
Returns `503` when any required check (db, scheduler) fails.
```json
{
"ready": true,
"checks": {"db": "ok", "scheduler": "ok", "ha": "na"},
"errors": [],
"version": "0.8.0"
}
```
### Kubernetes probe example
```yaml
livenessProbe:
httpGet:
path: /api/health
port: 8420
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/ready
port: 8420
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 2
```
The Docker compose file uses `/api/ready` as its healthcheck so the
container is only reported healthy after migrations finish.
## Metrics
Notify Bridge exposes Prometheus metrics at `GET /api/metrics` in the
standard text exposition format. **No authentication** — Prometheus
scrapers do not authenticate. Disable via `NOTIFY_BRIDGE_METRICS_ENABLED=false`
when the API port is reachable beyond the trust boundary.
### Prometheus scrape example
```yaml
scrape_configs:
- job_name: notify-bridge
metrics_path: /api/metrics
static_configs:
- targets: ['notify-bridge.internal:8420']
scrape_interval: 30s
```
### Available metrics
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `notify_bridge_deferred_pending` | Gauge | _(none)_ | Pending rows in `deferred_dispatch`. Refreshed on each scrape. A persistent non-zero value usually means a tracker target is in extended quiet hours. |
| `notify_bridge_event_log_total` | Counter | `status`, `event_type` | Events written to `event_log`. `status` is the dispatch outcome (`dispatched`, `dropped`, `deferred`, etc.). |
| `notify_bridge_dispatch_duration_seconds` | Histogram | `channel` | Wall-clock duration of one outbound dispatch (Telegram, Discord, email, …). Useful for latency alerts. |
| `notify_bridge_provider_poll_failures_total` | Counter | `provider_type` | Polling provider tick failures (Immich poll error, Gitea API down, …). Compare against expected scan interval to compute failure rate. |
| `notify_bridge_target_send_failures_total` | Counter | `target_type`, `status_code` | Failed sends to a notification channel. `status_code` is the HTTP status (or `0` when no HTTP response was received). |
The metrics module never imports `prometheus_client` outside `api/metrics.py`.
Other modules record events through the `metrics` singleton — see that
module's docstring before adding new collectors.
## Backups
Notify Bridge produces three different kinds of backup files. Know which
one you are looking at before restoring.
| Kind | Location | Format | Trigger |
| --- | --- | --- | --- |
| Config backup | `data/backups/backup-*.json` | JSON (BackupFile schema) | Manual via `/api/backup/files` POST or scheduled job |
| Pre-migration snapshot | `data/backups/pre-migrate-*.db` | SQLite DB file | Automatic on every boot before migrations |
| Pending restore | `data/pending_restore.json` | JSON | Staged via `/api/backup/prepare-restore`, consumed at next restart |
Config backups capture user configuration (providers, trackers, targets,
templates, …). They do **not** include `event_log`, `deferred_dispatch`,
or any other operational table. Pre-migration snapshots are full DB
copies and contain everything.
### Manual backup
The admin UI has a one-click button under Settings → Backup. Equivalent
HTTP call:
```bash
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude"
```
The download endpoint produces a downloadable JSON envelope with no
secrets unless `secrets_mode=include` is passed:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
-OJ "https://notify-bridge.example.com/api/backup/export?secrets_mode=exclude"
```
### Scheduled backup
Configure under Settings → Backup or via `PUT /api/backup/scheduled` with:
```json
{
"backup_scheduled_enabled": "true",
"backup_scheduled_interval_hours": "24",
"backup_secrets_mode": "exclude",
"backup_retention_count": "5"
}
```
Saved files land in `data/backups/`; retention prunes the oldest files
beyond `backup_retention_count`. Backups can be downloaded individually:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files/backup-2026-05-16T12-00-00.json" \
-o backup-latest.json
```
### Cron snippet for off-host backup
```bash
# /etc/cron.d/notify-bridge-backup
0 3 * * * www-data \
curl -fsS -X POST \
-H "Authorization: Bearer $(cat /etc/notify-bridge/admin.token)" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude" \
-o /var/backups/notify-bridge/backup-$(date +\%F).json
```
### Restore procedure
Restoring REPLACES configuration. Always export the current state first.
```bash
# 1. Stage the backup file (validates and writes to data/pending_restore.json)
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
-F "file=@backup-2026-05-16T12-00-00.json" \
"https://notify-bridge.example.com/api/backup/prepare-restore?conflict_mode=overwrite"
# 2. Trigger graceful restart so startup applies the staged restore.
# Same-origin Origin/Referer is enforced — call from the admin UI when
# possible, or from the same host. Requires the supervisor to respawn
# the process (Docker restart policy, systemd, PM2, etc.).
curl -fsS -X POST \
-H "Origin: https://notify-bridge.example.com" \
-H "Referer: https://notify-bridge.example.com/settings/backup" \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/apply-restart"
```
If the process is **not** supervised, `/api/backup/apply-restart` returns
`409`. Restart the backend manually after staging — startup applies the
pending restore on the next boot.
To cancel a staged restore before applying:
```bash
curl -fsS -X DELETE \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/pending-restore"
```
### Recovery from a corrupted DB
If migrations crash on boot or the DB file is unreadable, roll back to a
pre-migration snapshot:
```bash
# Stop the backend, then
cd /var/lib/docker/volumes/notify-bridge-data/_data
ls -1t backups/pre-migrate-*.db | head -5 # pick the snapshot
cp notify_bridge.db notify_bridge.db.broken # keep the broken DB for forensics
cp backups/pre-migrate-2026-05-16T11-58-30.db notify_bridge.db
rm -f notify_bridge.db-wal notify_bridge.db-shm # WAL belongs to the broken file
```
Restart the container. The startup snapshot will run again and capture
the rolled-back state, so you have a clean recovery point if the next
boot needs another rollback.
## Logs
* Output goes to **stderr only**. The Docker log driver captures it.
* Set `NOTIFY_BRIDGE_LOG_FORMAT=json` for line-delimited JSON suitable
for Loki, ELK, or CloudWatch.
* Secret values (bot tokens, API keys, passwords) are masked at the log
formatter level — see `notify_bridge_server.logging_setup`.
* No file rotation is built in. Use the Docker JSON log driver's
`max-size`/`max-file` options or send logs to your aggregator.
```yaml
# docker-compose.yml snippet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
```
## Common operational scenarios
### "Notifications stopped firing"
1. Hit `/api/ready`. If `scheduler` is `fail`, restart the backend; the
scheduler died in a way it cannot recover from.
2. Check `notify_bridge_deferred_pending`. A non-zero value during quiet
hours is normal; a value that grows monotonically across days is a
bug — inspect the `deferred_dispatch` table.
3. Inspect the most recent `event_log` rows in the admin Events page or:
```sql
SELECT created_at, event_type, dispatch_status, details
FROM event_log
ORDER BY created_at DESC LIMIT 50;
```
Look for a `dispatch_status` other than `dispatched`.
4. If a single tracker is silent, verify the provider's last poll status
in the admin UI (Providers page) — `notify_bridge_provider_poll_failures_total`
tells you which provider type is failing.
5. If you've configured a `bridge_self` tracker but never received a
self-monitoring alert when something failed, see the next section —
`bridge_self` failures are deliberately log-only to prevent recursion.
### Bridge self-monitoring is log-only on its own failures
The built-in `bridge_self` provider emits notifications when polls,
dispatches, or target sends fail. To prevent infinite-recursion (a
`bridge_self` notification failing → triggering another `bridge_self`
notification → ...), failures of `bridge_self` events themselves are
**not** counted toward target-failure thresholds and are logged only.
If your `bridge_self` notifications stop arriving, it means the
notification target you wired them to is itself failing. Grep stderr for:
```text
bridge_self target-failure emission failed
emit_bridge_self_event failed
```
The fix is always at the target layer (Telegram bot blocked, Matrix
homeserver down, SMTP credentials rotated). The bridge cannot tell you
about its own outbound failure — that's what the operator's external
monitoring (Prometheus alert on `notify_bridge_target_send_failures_total`)
is for.
### "Webhook returns 500"
Inspect the `webhook_payload_log` table for the matching request:
```sql
SELECT received_at, status_code, error_message, payload_excerpt
FROM webhook_payload_log
ORDER BY received_at DESC LIMIT 20;
```
Common causes: payload schema change in the source service, a tracker
referencing a deleted provider, a Jinja template that errors out (look
for `template render failed` in logs).
### "Telegram bot rate-limited (429)"
The Telegram client implements exponential backoff with jitter on
`Retry-After`. No operator action is required for transient throttling.
If the rate-limit persists, check:
* The bot is being driven by multiple Notify Bridge instances pointing
at the same chat (split-brain — only one instance should own a bot).
* A template is producing very large messages (Telegram limits message
size to 4096 chars). Look for `MessageTooLong` in the logs.
### "DB lock contention"
SQLite WAL mode and `busy_timeout=10000` make this rare. If you see
`SQLITE_BUSY` in logs:
* Check for long-running transactions (most often a stuck migration).
* Confirm the WAL files are on the same filesystem as the main DB —
splitting them across mounts is a known cause.
* Run `sqlite3 notify_bridge.db "PRAGMA wal_checkpoint(TRUNCATE);"` to
flush the WAL. Safe to run while the backend is up.
## Upgrades
1. Pre-migration snapshot is taken automatically before any migration
runs. The latest five snapshots are retained by default.
2. Migrations are idempotent — re-running an upgrade is safe.
3. If a migration fails, the snapshot from step 1 is the recovery point.
See "Recovery from a corrupted DB" above.
4. Always test major version upgrades in staging first. The upgrade flow
is the same in staging: pull the new image, restart the container.
The release tag stream lives at the project Gitea / GitHub releases page.
Release notes are written to `RELEASE_NOTES.md` for the upcoming version
and copied into the Gitea release body by the `release.yml` workflow.
+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.
+41 -3
View File
@@ -1,9 +1,47 @@
# v0.6.1 (2026-04-25)
# v0.10.0 (2026-06-05)
Small visual follow-up to the Aurora redesign: the **Active Wires pipe** on the dashboard now reads more prominently.
Multi-time-point scheduling for Immich. The single comma-separated "times" text box is replaced with an add/remove list of native HH:MM pickers for the **scheduled assets**, **periodic summary**, and **memory** slots — entering several fire times per day is now discoverable, and the read/write path is hardened end to end. This release also pins `aiohttp<3.14` in the backend test environment so the suite stays green against the current `aioresponses`. No breaking changes; no migrations required.
## User-facing changes
### Features
- **Immich multi-time scheduling UI.** A new `TimeListEditor` replaces the comma-separated text box with an add/remove list of native HH:MM pickers for the scheduled-assets, periodic-summary, and memory slots. It dedupes and sorts on save, enforces a per-day cap, collapses on-screen duplicates, and gives each row an aria-label. Keyboard entry is no longer clobbered mid-edit — it syncs from the value prop only on external changes via an `untrack` guard ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Per-section save guard.** An enabled feature section (scheduled / periodic / memory) must now have at least one time before it can be saved, enforced in the provider descriptor ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Refreshed copy.** New en/ru i18n keys and updated help text for the editor; the dead `invalidTimeList` string was removed ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Bug Fixes
- Make Active Wires pipe visually prominent ([cc8d961](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc8d961))
- **Server-side `*_times` normalization.** The `tracking_configs` API now normalizes `scheduled_times` / `periodic_times` / `memory_times` on every write (validate, dedup, sort, cap at 24), returns **422** on malformed input, and refuses to enable a slot whose times list is empty ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Restored scheduler observability.** The scheduler now warns when an enabled slot has zero or dropped fire times, restoring the visibility that was lost when the old per-call warning was removed ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
---
## Development / Internal
### CI/Build
- **Pin `aiohttp<3.14` in the backend test install.** `aioresponses` 0.7.8 (latest) does not pass the `stream_writer` keyword argument that aiohttp 3.14 made required on `ClientResponse`, so every aioresponses-mocked HTTP test failed to construct a response. `notify-bridge-core` declares `aiohttp>=3.9` with no upper bound, so CI floated to 3.14.0 — the pin keeps the test environment reproducible in both `build.yml` and `release.yml` until aioresponses ships an aiohttp-3.14-compatible release ([11593ea](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/11593ea))
### Refactoring
- **Shared time-list service.** A new `services/time_list.py` exposes `normalize_time_list` (raising `TimeListError`) and a lenient `parse_hhmm_list`; the scheduler drops its private parser copy in favour of the shared one ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Tests
- **Time-list coverage.** New tests for time-list normalization and parsing (including non-ASCII and odd-shaped input) and for the "enabled implies at least one time" validation ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Chores
- **Checked-in vex config.** A `.vex.toml` enabling semantic embeddings, call-graph, BM25, and auto-update for the vex code-search index ([d01e519](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d01e519))
---
<details>
<summary>All Commits</summary>
- [8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e) — `feat(immich): multi-time-point scheduling for scheduled/periodic/memory` (alexei.dolgolyov)
- [11593ea](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/11593ea) — `fix(ci): pin aiohttp<3.14 in backend test deps` (alexei.dolgolyov)
- [d01e519](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d01e519) — `chore: add vex semantic-search config` (alexei.dolgolyov)
</details>
+16 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.10.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.10.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",
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
},
"@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.6.1",
"version": "0.10.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",
+101 -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; }
@@ -459,3 +505,52 @@ button:focus-visible, a:focus-visible {
scroll-behavior: auto !important;
}
}
/* Shared toggle switch — used by provider config forms, tracking-config
extraTrackingFields, and anywhere else we render a boolean field.
Kept global so adding a new ConfigField type='toggle' caller doesn't
need to copy the CSS into its scoped <style>. */
.toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
height: 1.75rem;
}
.toggle-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch .toggle-track {
position: relative;
width: 2.5rem;
height: 1.375rem;
background: var(--color-border);
border-radius: 9999px;
transition: background 0.2s ease;
}
.toggle-switch .toggle-track::after {
content: '';
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1rem;
height: 1rem;
background: var(--color-foreground);
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle-switch input:checked + .toggle-track::after {
transform: translateX(1.125rem);
background: var(--color-primary-foreground);
}
+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 -8
View File
@@ -2,8 +2,41 @@
* API client with JWT auth for the Notify Bridge backend.
*/
import { goto } from '$app/navigation';
const API_BASE = '/api';
/**
* Thrown when the API client decides to redirect the user to /login (after a
* terminal 401). Caller-side `try/catch` blocks can branch on
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
* — the redirect itself is the user-visible signal.
*/
export class AuthRedirectError extends Error {
constructor() {
super('Unauthorized — redirecting to login');
this.name = 'AuthRedirectError';
}
}
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
// the dashboard's parallel cache loads) should only schedule a single
// `goto('/login')` instead of stacking N navigations.
let _redirecting = false;
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
function redirectToLogin(): void {
if (_redirecting) return;
_redirecting = true;
clearTokens();
if (typeof window !== 'undefined') {
// SvelteKit's goto() with replaceState avoids leaving the failed page
// in the back-stack (no "back-button to broken view" UX). We don't
// reset `_redirecting` — the page about to unmount makes it moot.
goto('/login', { replaceState: true });
}
}
/** Normalize a caught error to a user-safe message. */
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
if (err instanceof Error && err.message) return err.message;
@@ -129,11 +162,11 @@ export async function api<T = any>(
}
if (res.status === 401 && token) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
redirectToLogin();
// Tagged so the caller's catch can distinguish "we already showed
// the user a redirect" from a real authorization failure they
// should snackbar.
throw new AuthRedirectError();
}
if (res.status === 204) return undefined as T;
@@ -204,9 +237,8 @@ export async function fetchAuth(
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
redirectToLogin();
throw new AuthRedirectError();
}
if (!res.ok) {
+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,457 @@
<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.
IDs are snapshotted into local consts so the deferred onclick
closures don't lose the narrowed type that the `{#if ...}` gate
proves at template-render time. -->
<div class="actions">
{#if displayEvent.provider_id}
{@const providerId = displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', providerId)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if displayEvent.telegram_bot_id && isCommand}
{@const botId = displayEvent.telegram_bot_id}
<button type="button" onclick={() => openEntity('/bots', botId)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if displayEvent.command_tracker_id && isCommand}
{@const cmdTrackerId = displayEvent.command_tracker_id}
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if displayEvent.action_id && isAction}
{@const actionId = displayEvent.action_id}
<button type="button" onclick={() => openEntity('/actions', actionId)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && displayEvent.tracker_id}
{@const trackerId = displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
<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>
@@ -17,6 +17,7 @@
columns = 2,
disabled = false,
compact = false,
onChange,
}: {
items: GridItem[];
value: string | number | null;
@@ -24,6 +25,13 @@
columns?: number;
disabled?: boolean;
compact?: boolean;
/**
* Optional one-way change callback. Fired in addition to updating
* `value` so callers that own state externally (e.g. a global store)
* can avoid the read-modify-write feedback loop that `bind:value` plus
* a sync `$effect` produces.
*/
onChange?: (value: string | number) => void;
} = $props();
let open = $state(false);
@@ -63,6 +71,7 @@
value = item.value;
open = false;
search = '';
onChange?.(item.value);
}
function handleKeydown(e: KeyboardEvent) {
@@ -195,6 +204,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;
+4 -1
View File
@@ -32,12 +32,15 @@
</script>
{#if snacks.length > 0}
<div use:portal class="snackbar-container">
<div use:portal class="snackbar-container" role="region" aria-label={t('snackbar.region')}>
{#each snacks as snack (snack.id)}
<div
in:fly={{ y: 40, duration: 300 }}
out:fade={{ duration: 200 }}
class="snack-item"
role={snack.type === 'error' ? 'alert' : 'status'}
aria-live={snack.type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
style="--snack-accent: {accentMap[snack.type]};"
>
<span class="snack-icon" style="color: {accentMap[snack.type]};">
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
/**
* Free-text chip input. Bind a string[] of values; commit a new chip on
* Enter, comma, or blur. Backspace on empty input deletes the last chip
* for parity with native chip-input UX.
*
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
* free-text filter keys like Home Assistant's entity_glob and
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
* picker dropdown sourced from an enumerable list.
*/
import MdiIcon from './MdiIcon.svelte';
interface Props {
values: string[];
onchange: (values: string[]) => void;
placeholder?: string;
icon?: string;
/** Strip / reject anything matching this regex on each entry. */
sanitize?: (raw: string) => string | null;
}
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
let draft = $state('');
function addRaw(raw: string): void {
const trimmed = raw.trim();
if (!trimmed) return;
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
if (!cleaned) return;
if (values.includes(cleaned)) return;
onchange([...values, cleaned]);
}
function commitDraft(): void {
if (!draft.trim()) return;
// Allow comma-separated paste — split on commas and add each.
for (const piece of draft.split(',')) {
addRaw(piece);
}
draft = '';
}
function removeAt(index: number): void {
onchange(values.filter((_, i) => i !== index));
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
e.preventDefault();
removeAt(values.length - 1);
}
}
</script>
<div class="tag-input">
{#each values as value, i (`${i}-${value}`)}
<span class="tag-chip">
{#if icon}<MdiIcon name={icon} size={12} />{/if}
<span class="tag-text">{value}</span>
<button
type="button"
aria-label="Remove"
class="tag-remove"
onclick={() => removeAt(i)}
>×</button>
</span>
{/each}
<input
type="text"
bind:value={draft}
onkeydown={onKey}
onblur={commitDraft}
placeholder={values.length === 0 ? placeholder : ''}
class="tag-draft"
autocomplete="off"
spellcheck="false"
/>
</div>
<style>
.tag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
min-height: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-background);
cursor: text;
}
.tag-input:focus-within {
border-color: var(--color-primary);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
background: var(--color-muted);
border-radius: 9999px;
font-size: 0.75rem;
line-height: 1;
color: var(--color-foreground);
}
.tag-text {
font-family: var(--font-mono, monospace);
}
.tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
font-size: 0.875rem;
line-height: 1;
background: transparent;
border: none;
color: var(--color-muted-foreground);
cursor: pointer;
border-radius: 9999px;
}
.tag-remove:hover {
background: var(--color-border);
color: var(--color-foreground);
}
.tag-draft {
flex: 1;
min-width: 8rem;
border: none;
outline: none;
background: transparent;
font-size: 0.875rem;
color: var(--color-foreground);
padding: 0.125rem 0;
}
.tag-draft::placeholder {
color: var(--color-muted-foreground);
}
</style>
@@ -0,0 +1,252 @@
<script lang="ts">
/**
* Multi-time-point editor — an add/remove list of native HH:MM pickers.
*
* Used in the tracking-configs form for the Immich scheduled / periodic /
* memory slots, which fire at one or more wall-clock times per day. The
* value crosses the wire as a single comma-separated string ("09:00,18:30")
* matching the backend `normalize_time_list`: each emitted value is
* de-duplicated and sorted ascending.
*
* Internally `rows` is the rendering source of truth and preserves the
* user's current edit state (including a blank, not-yet-filled row) so the
* list never jumps around mid-edit. Normalisation is applied only to the
* emitted value, not to what's on screen; the on-screen order re-settles to
* sorted form on the next external load.
*/
import { untrack } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
interface Props {
/** Comma-separated HH:MM list, e.g. "09:00,18:30". */
value: string;
/** Called with a normalised (deduped, sorted) comma-separated string. */
onchange: (value: string) => void;
/** Maximum number of distinct times allowed. */
max?: number;
/** Placeholder forwarded to each native time input. */
placeholder?: string;
}
let { value, onchange, max = 24, placeholder = '' }: Props = $props();
let rows = $state<string[]>([]);
// The exact string we last synced-from or emitted. The resync effect below
// compares against this so it fires ONLY for genuine external `value` changes
// (form reset, config switch) — never for the user's own keystrokes. Without
// this guard the effect re-ran on every `bind:value` mutation while `value`
// still held the old committed string, clobbering the row being typed in (the
// native picker reset just as the user reached the AM/PM segment).
let lastValue = '';
function parse(raw: string): string[] {
return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
}
/** Distinct, non-blank times, in first-seen order. */
function distinct(list: string[]): string[] {
const seen = new Set<string>();
for (const r of list) {
const v = r.trim();
if (v) seen.add(v);
}
return [...seen];
}
// Populate on mount and re-sync on external `value` changes only. `value` is
// the sole reactive dependency; `rows` is read/written inside `untrack`, so
// editing a row never re-triggers this and can't clobber an in-progress edit.
// `$effect.pre` runs before the first paint, so initial rows show without a
// flash of the empty state. HH:MM is zero-padded, so a lexical sort is
// chronological.
$effect.pre(() => {
const incoming = value ?? '';
untrack(() => {
if (incoming !== lastValue) {
rows = parse(incoming);
lastValue = incoming;
}
});
});
function emit(): void {
const next = distinct(rows).sort().join(',');
// Record before emitting so the value round-trip back through the parent
// isn't mistaken for an external change and used to reset `rows`.
lastValue = next;
onchange(next);
}
// Fired when a row's picker changes. Collapse any on-screen duplicate of an
// already-present time (keeping the first occurrence and any blank
// in-progress row) so the displayed rows match what's persisted — emit()
// dedups the saved value, and without this the screen would keep showing two
// identical rows. Then emit the canonical value.
function onRowChange(): void {
const seen = new Set<string>();
const next: string[] = [];
for (const r of rows) {
const v = r.trim();
if (!v) { next.push(r); continue; }
if (seen.has(v)) continue;
seen.add(v);
next.push(r);
}
if (next.length !== rows.length) rows = next;
emit();
}
const filledCount = $derived(distinct(rows).length);
const hasBlankRow = $derived(rows.some((r) => !r.trim()));
const atMax = $derived(filledCount >= max);
// Block adding while a blank row is open (fill it first) or the cap is hit —
// keeps the list from stacking empty rows and respects the per-day limit.
const canAdd = $derived(!atMax && !hasBlankRow);
function addRow(): void {
if (!canAdd) return;
rows = [...rows, ''];
}
function removeRow(index: number): void {
rows = rows.filter((_, i) => i !== index);
emit();
}
</script>
<div class="time-list">
{#each rows as _, i (i)}
<div class="time-row">
<input
type="time"
bind:value={rows[i]}
onchange={onRowChange}
{placeholder}
aria-label={t('trackingConfig.timeRowLabel').replace('{n}', String(i + 1))}
class="time-input"
/>
<button
type="button"
class="time-remove"
aria-label={t('trackingConfig.removeTime')}
onclick={() => removeRow(i)}
>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
{/each}
{#if rows.length === 0}
<p class="time-empty">
<MdiIcon name="mdiClockOutline" size={12} />
<span>{t('trackingConfig.noTimes')}</span>
</p>
{/if}
{#if atMax}
<p class="time-cap">{t('trackingConfig.maxTimesReached').replace('{n}', String(max))}</p>
{:else}
<button type="button" class="time-add" disabled={!canAdd} onclick={addRow}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('trackingConfig.addTime')}</span>
</button>
{/if}
</div>
<style>
.time-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.time-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.time-input {
flex: 1;
min-width: 0;
padding: 0.3125rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-background);
color: var(--color-foreground);
font-size: 0.875rem;
line-height: 1.25;
font-variant-numeric: tabular-nums;
}
.time-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.time-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: color 0.12s ease, background 0.12s ease;
}
.time-remove:hover {
background: var(--color-muted);
color: var(--color-error-fg);
}
.time-add {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3125rem;
width: 100%;
padding: 0.3125rem 0.5rem;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
font-size: 0.8125rem;
line-height: 1.25;
cursor: pointer;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.time-add:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-muted);
}
.time-add:disabled {
opacity: 0.5;
cursor: default;
}
.time-empty {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-muted-foreground);
}
.time-cap {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
text-align: center;
}
</style>
@@ -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;
}
+56 -1
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,29 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Provider stats scope (dashboard "On watch" deck) ---
//
// Toggles whether the provider deck stats reflect only the events visible
// on the current page or aggregate across all events matching the filters.
export const providerStatsModeItems = (): GridItem[] => [
{ value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') },
{ value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') },
];
// --- 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[] => [
@@ -143,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
...allDescriptors().map(descriptorToGridItem),
];
/** Provider types the user is allowed to create from the "new provider" wizard.
*
* Excludes ``bridge_self`` because it's auto-created exactly once per user
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
* wizard would either duplicate the row or surface a confusing 409.
*/
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
allDescriptors()
.filter((d) => d.type !== 'bridge_self')
.map((d) => d.type);
/** Provider type selector (no "All" option). */
export const providerTypeItems = (): GridItem[] =>
allDescriptors().map(descriptorToGridItem);
allDescriptors()
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
.map(descriptorToGridItem);
+300 -11
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",
@@ -125,6 +157,9 @@
"eventsLabel": "events",
"onWatchTitle": "On",
"onWatchEmphasis": "watch",
"statsModeTitle": "Provider deck stats scope",
"statsModePage": "Page",
"statsModeAll": "All",
"noProviders": "No providers yet.",
"addProvider": "Add provider",
"addProviderHint": "Connect a service to start tracking",
@@ -141,6 +176,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",
@@ -169,6 +235,20 @@
"typeNut": "NUT (UPS)",
"typeGooglePhotos": "Google Photos",
"typeWebhook": "Generic Webhook",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Bridge Self-Monitoring",
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
"bridgeSelfTargetThreshold": "Target send failure threshold",
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
"haAccessToken": "Long-Lived Access Token",
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
"haAccessTokenRequired": "Home Assistant access token is required.",
"haVerifyTls": "Verify TLS certificate",
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -192,7 +272,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,10 +327,20 @@
"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",
"selectUpsDevices": "Select UPS devices...",
"entities": "Entities",
"selectEntities": "Select entities...",
"entities_count": "entity(ies)",
"haEntityGlob": "Entity glob filter",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Domain allowlist",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Event Types",
"notificationTargets": "Notification Targets",
"scanInterval": "Scan Interval (seconds)",
@@ -309,6 +400,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}\"",
@@ -388,6 +480,7 @@
"videoWarning": "Video size warning",
"disableUrlPreview": "Disable link previews",
"sendLargeAsDocuments": "Send large photos as documents",
"sendLargeVideosAsDocuments": "Send oversized videos as documents (bypass 50 MB limit)",
"chatAction": "Chat action",
"chatActionNone": "None (no action)",
"chatActionTyping": "Typing",
@@ -416,13 +509,25 @@
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
"receiverDisabled": "Receiver disabled",
"telegramOptions": "Telegram options",
"telegramOptionsSaved": "Telegram options saved",
"telegramDisableNotification": "Send silently (no sound / vibration)",
"telegramThreadId": "Forum topic ID",
"telegramThreadIdPlaceholder": "Leave empty for general topic",
"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 +577,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",
@@ -565,6 +671,14 @@
"upsOverload": "UPS overloaded",
"scheduledMessage": "Scheduled message",
"webhookReceived": "Webhook received",
"haStateChanged": "Entity state changed",
"haAutomationTriggered": "Automation triggered",
"haServiceCalled": "Service called",
"haEventFired": "Other HA event (catch-all)",
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
"bridgeSelfPollFailures": "Tracker poll failures",
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
"bridgeSelfTargetFailures": "Target send failures",
"trackImages": "Track images",
"trackVideos": "Track videos",
"favoritesOnly": "Favorites only",
@@ -614,8 +728,13 @@
"deleted": "deleted",
"providerType": "Provider Type",
"sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"timesInlineHelp": "One or more times per day",
"addTime": "Add time",
"removeTime": "Remove time",
"timeRowLabel": "Time {n}",
"noTimes": "No times set — add at least one",
"maxTimesReached": "Maximum {n} times reached",
"timesRequiredFor": "Add at least one time for \"{slot}\"",
"previewTemplate": "Preview template",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
@@ -627,6 +746,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 +903,108 @@
"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",
"diagnostics": "Diagnostics",
"diagnosticsHeadline": "Temporary DEBUG for one module, auto-reverted",
"diagnosticsHint": "Use to investigate a specific dispatch failure without flooding stderr. The chosen module flips to DEBUG immediately and reverts to its baseline (your per-module overrides or the noisy-library defaults) when the window ends. Restarts also reset.",
"diagModuleQuick": "Module (quick pick)",
"diagModuleCustom": "Or a custom module name",
"diagModuleCustomPlaceholder": "e.g. notify_bridge_server.services.deferred_dispatch",
"diagModuleRequired": "Pick a module first",
"diagDuration": "Duration",
"diagActivate": "Activate DEBUG",
"diagActivated": "Diagnostic mode activated",
"diagActivateFailed": "Failed to activate diagnostic mode",
"diagActive": "Active overrides",
"diagRevertsIn": "Reverts in",
"diagRevertNow": "Revert now",
"diagReverted": "Diagnostic mode reverted",
"diagRevertFailed": "Failed to revert diagnostic mode",
"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.",
@@ -795,7 +1016,7 @@
"maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
"times": "Time(s) of day to send notifications. Add as many time points per day as you need.",
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
@@ -818,7 +1039,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 +1096,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",
@@ -926,9 +1154,22 @@
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"commands": {
"bridgeSelf": {
"status": "Bridge status",
"statusDesc": "Show current bridge health counters",
"thresholds": "Bridge thresholds",
"thresholdsDesc": "Show configured alert thresholds",
"reset": "Reset counter",
"resetDesc": "Manually reset a failure counter",
"health": "Bridge health",
"healthDesc": "Terse one-line health summary"
}
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
"hideDetails": "Hide details",
"region": "Notifications"
},
"timezone": {
"searchPlaceholder": "Search cities or IANA codes…",
@@ -937,9 +1178,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",
@@ -1005,6 +1249,7 @@
},
"common": {
"loading": "Loading...",
"auto": "Auto",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@@ -1012,6 +1257,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1119,6 +1366,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 +1383,16 @@
"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",
"statsModePage": "Count only events on the current page",
"statsModeAll": "Count all events matching the current filters",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1152,7 +1415,9 @@
"providerScheduler": "Time-based scheduled messages",
"providerNut": "Network UPS monitoring",
"providerGooglePhotos": "Google Photos albums & shared libraries",
"providerWebhook": "Receive events via HTTP POST"
"providerWebhook": "Receive events via HTTP POST",
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
},
"webhookLogs": {
"title": "Recent Payloads",
@@ -1312,6 +1577,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"
}
}
+300 -11
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": "Активность событий",
@@ -125,6 +157,9 @@
"eventsLabel": "событий",
"onWatchTitle": "На",
"onWatchEmphasis": "слежении",
"statsModeTitle": "Область статистики провайдеров",
"statsModePage": "Страница",
"statsModeAll": "Все",
"noProviders": "Пока нет провайдеров.",
"addProvider": "Добавить",
"addProviderHint": "Подключите сервис, чтобы начать слежение",
@@ -141,6 +176,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": "провайдеры",
@@ -169,6 +235,20 @@
"typeNut": "NUT (ИБП)",
"typeGooglePhotos": "Google Фото",
"typeWebhook": "Универсальный вебхук",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Самомониторинг моста",
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
"haAccessToken": "Долгоживущий токен доступа",
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
"haVerifyTls": "Проверять TLS-сертификат",
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -192,7 +272,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,10 +327,20 @@
"selectAlbums": "Выберите альбомы...",
"repositories": "Репозитории",
"selectRepositories": "Выберите репозитории...",
"userAllowlist": "Только от пользователей",
"userBlocklist": "Исключить пользователей",
"selectUsers": "Выберите пользователей...",
"boards": "Доски",
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
"selectUpsDevices": "Выберите ИБП...",
"entities": "Сущности",
"selectEntities": "Выберите сущности...",
"entities_count": "сущность(ей)",
"haEntityGlob": "Фильтр по entity (glob)",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Разрешённые домены",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Типы событий",
"notificationTargets": "Получатели уведомлений",
"scanInterval": "Интервал проверки (секунды)",
@@ -309,6 +400,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -388,6 +480,7 @@
"videoWarning": "Предупреждение о размере видео",
"disableUrlPreview": "Отключить превью ссылок",
"sendLargeAsDocuments": "Отправлять большие фото как документы",
"sendLargeVideosAsDocuments": "Отправлять видео сверх лимита как документы (обход 50 МБ)",
"chatAction": "Действие в чате",
"chatActionNone": "Нет (без действия)",
"chatActionTyping": "Печатает",
@@ -416,13 +509,25 @@
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
"receiverDisabled": "Получатель отключён",
"telegramOptions": "Параметры Telegram",
"telegramOptionsSaved": "Параметры Telegram сохранены",
"telegramDisableNotification": "Отправлять без звука и вибрации",
"telegramThreadId": "ID темы форума",
"telegramThreadIdPlaceholder": "Оставьте пустым для общей темы",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
"target": "получатель",
"targetsLower": "получателей",
"openBot": "Открыть бота"
},
"users": {
"titleEmphasis": "и доступ",
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -472,6 +577,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
@@ -565,6 +671,14 @@
"upsOverload": "Перегрузка ИБП",
"scheduledMessage": "Запланированное сообщение",
"webhookReceived": "Вебхук получен",
"haStateChanged": "Состояние сущности изменилось",
"haAutomationTriggered": "Сработала автоматизация",
"haServiceCalled": "Вызвана служба",
"haEventFired": "Прочее событие HA (catch-all)",
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
"bridgeSelfPollFailures": "Сбои опроса трекера",
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
"trackImages": "Фото",
"trackVideos": "Видео",
"favoritesOnly": "Только избранные",
@@ -614,8 +728,13 @@
"deleted": "удалён",
"providerType": "Тип провайдера",
"sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"timesInlineHelp": "Одно или несколько значений времени в день",
"addTime": "Добавить время",
"removeTime": "Удалить время",
"timeRowLabel": "Время {n}",
"noTimes": "Время не задано — добавьте хотя бы одно",
"maxTimesReached": "Достигнут максимум: {n}",
"timesRequiredFor": "Добавьте хотя бы одно время для «{slot}»",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
@@ -627,6 +746,7 @@
"countLabel": "шаблонов",
"title": "Конфигурации шаблонов",
"description": "Определите формат уведомлений",
"language": "Язык",
"providerType": "Тип сервис-провайдера",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -783,7 +903,108 @@
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Настройки сохранены"
"saved": "Настройки сохранены",
"identity": "Идентификация",
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
"diagnostics": "Диагностика",
"diagnosticsHeadline": "Временный DEBUG для одного модуля с авто-возвратом",
"diagnosticsHint": "Включите, чтобы разобраться в конкретной ошибке отправки без заливания stderr. Выбранный модуль немедленно переходит в DEBUG и возвращается к базовому уровню (вашим переопределениям или умолчаниям для шумных библиотек) по истечении окна. При перезапуске сервера всё сбрасывается.",
"diagModuleQuick": "Модуль (быстрый выбор)",
"diagModuleCustom": "Или произвольное имя модуля",
"diagModuleCustomPlaceholder": "напр. notify_bridge_server.services.deferred_dispatch",
"diagModuleRequired": "Сначала выберите модуль",
"diagDuration": "Длительность",
"diagActivate": "Включить DEBUG",
"diagActivated": "Режим диагностики включён",
"diagActivateFailed": "Не удалось включить режим диагностики",
"diagActive": "Активные переопределения",
"diagRevertsIn": "Вернётся через",
"diagRevertNow": "Вернуть сейчас",
"diagReverted": "Режим диагностики отменён",
"diagRevertFailed": "Не удалось отменить режим диагностики",
"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": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -795,7 +1016,7 @@
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"times": "Время отправки уведомлений. Добавьте сколько угодно значений времени в день.",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
@@ -818,7 +1039,11 @@
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
},
"matrixBot": {
"titleEmphasis": "matrix",
@@ -871,12 +1096,15 @@
"noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию."
"commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
},
"commandConfig": {
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -926,9 +1154,22 @@
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"commands": {
"bridgeSelf": {
"status": "Состояние моста",
"statusDesc": "Показать счётчики состояния моста",
"thresholds": "Пороги моста",
"thresholdsDesc": "Показать настроенные пороги оповещений",
"reset": "Сбросить счётчик",
"resetDesc": "Вручную сбросить счётчик сбоев",
"health": "Здоровье моста",
"healthDesc": "Краткая однострочная сводка состояния"
}
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
"hideDetails": "Скрыть детали",
"region": "Уведомления"
},
"timezone": {
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
@@ -937,9 +1178,12 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"customPlaceholder": "или de-CH",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
@@ -1005,6 +1249,7 @@
},
"common": {
"loading": "Загрузка...",
"auto": "Авто",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
@@ -1012,6 +1257,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1119,6 +1366,12 @@
"memorySourceNative": "Использовать API воспоминаний Immich",
"localeEn": "Английский интерфейс",
"localeRu": "Русский интерфейс",
"logLevelDebug": "Подробный — каждый шаг",
"logLevelInfo": "По умолчанию — ключевые события",
"logLevelWarning": "Только предупреждения и ошибки",
"logLevelError": "Только ошибки — самый тихий",
"logFormatText": "Читаемый человеком текст",
"logFormatJson": "Один JSON-объект на строку",
"modeMedia": "Отправка файлов фото/видео",
"modeText": "Только имена файлов и ссылки",
"allEvents": "Показать все типы событий",
@@ -1130,6 +1383,16 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"statsModePage": "Учитывать только события на текущей странице",
"statsModeAll": "Учитывать все события под текущими фильтрами",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1152,7 +1415,9 @@
"providerScheduler": "Запланированные сообщения по расписанию",
"providerNut": "Мониторинг ИБП через NUT",
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
"providerWebhook": "Приём событий через HTTP POST"
"providerWebhook": "Приём событий через HTTP POST",
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
},
"webhookLogs": {
"title": "Последние запросы",
@@ -1312,6 +1577,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(),
};
}
+98
View File
@@ -0,0 +1,98 @@
import type { ProviderDescriptor } from './types';
/**
* Bridge self-monitoring provider descriptor.
*
* The bridge_self provider has no remote URL and no credentials. The only
* configuration surface is the three thresholds below, used by the server
* to decide when an internal failure deserves a notification.
*
* Exactly one bridge_self provider exists per user, auto-seeded on user
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
*/
export const bridgeSelfDescriptor: ProviderDescriptor = {
type: 'bridge_self',
defaultName: 'Bridge Self-Monitoring',
icon: 'mdiAlertCircleOutline',
hasUrl: false,
configFields: [
{
key: 'poll_failure_threshold',
configKey: 'poll_failure_threshold',
label: 'providers.bridgeSelfPollThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 3,
hint: 'providers.bridgeSelfPollThresholdHint',
},
{
key: 'deferred_backlog_threshold',
configKey: 'deferred_backlog_threshold',
label: 'providers.bridgeSelfDeferredThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 100,
hint: 'providers.bridgeSelfDeferredThresholdHint',
},
{
key: 'target_failure_threshold',
configKey: 'target_failure_threshold',
label: 'providers.bridgeSelfTargetThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 5,
hint: 'providers.bridgeSelfTargetThresholdHint',
},
],
buildConfig(form) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return {
config: {
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
target_failure_threshold: toInt(form.target_failure_threshold, 5),
},
};
},
hasConfigChanged(form, existing) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return (
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
);
},
eventFields: [
{
key: 'track_bridge_self_poll_failures',
label: 'trackingConfig.bridgeSelfPollFailures',
default: true,
},
{
key: 'track_bridge_self_deferred_backlog',
label: 'trackingConfig.bridgeSelfDeferredBacklog',
default: true,
},
{
key: 'track_bridge_self_target_failures',
label: 'trackingConfig.bridgeSelfTargetFailures',
default: true,
},
],
collectionMeta: null,
webhookBased: false,
};
+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}',
};
@@ -0,0 +1,96 @@
import type { ProviderDescriptor } from './types';
export const homeAssistantDescriptor: ProviderDescriptor = {
type: 'home_assistant',
defaultName: 'Home Assistant',
icon: 'mdiHomeAssistant',
hasUrl: true,
urlPlaceholder: 'http://homeassistant.local:8123',
configFields: [
{
key: 'access_token', configKey: 'access_token',
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
},
{
key: 'verify_tls', configKey: 'verify_tls',
label: 'providers.haVerifyTls',
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
defaultValue: true,
},
],
buildConfig(form, editing) {
const config: Record<string, unknown> = { url: form.url };
if (form.access_token) config.access_token = form.access_token;
// Coerce truthy/falsy form values to a real boolean. The toggle
// control binds to `checked`, so this is normally already a bool,
// but legacy form state may carry the string defaults.
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
if (!editing && !form.access_token) {
return { config, error: 'providers.haAccessTokenRequired' };
}
return { config };
},
hasConfigChanged(form, existing) {
const existingVerify = existing.verify_tls !== false;
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
return (
form.url !== (existing.url || '') ||
!!form.access_token ||
existingVerify !== formVerify
);
},
eventFields: [
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
{
key: 'track_ha_event_fired',
label: 'trackingConfig.haEventFired',
default: false,
hint: 'trackingConfig.haEventFiredHint',
},
],
// entity_glob / domain_allowlist tag-style filters. Stored on the
// tracker's `filters` JSON column (not the flat form root) — the
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
// than a picker, and `filterKey` routes the value into
// `tracker.filters[filterKey]` at save time.
userFilters: [
{
key: 'entity_glob',
filterKey: 'entity_glob',
inputMode: 'tags',
label: 'notificationTracker.haEntityGlob',
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
icon: 'mdiAsterisk',
},
{
key: 'domain_allowlist',
filterKey: 'domain_allowlist',
inputMode: 'tags',
label: 'notificationTracker.haDomainAllowlist',
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
icon: 'mdiTagOutline',
},
],
collectionMeta: {
label: 'notificationTracker.entities',
icon: 'mdiViewList',
placeholder: 'notificationTracker.selectEntities',
countLabel: 'notificationTracker.entities_count',
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
const parts: string[] = [];
if (col.domain) parts.push(col.domain);
if (col.state) parts.push(col.state);
if (parts.length === 0) return col.entity_id || col.id || '';
return parts.join(' · ');
},
},
};
+15 -3
View File
@@ -13,6 +13,7 @@ export const immichDescriptor: ProviderDescriptor = {
icon: 'mdiImageMultiple',
hasUrl: true,
urlPlaceholder: undefined, // uses generic i18n placeholder
supportsAutoOrganize: true,
configFields: [
{
@@ -67,14 +68,14 @@ export const immichDescriptor: ProviderDescriptor = {
fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
],
},
{
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
@@ -86,7 +87,7 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
enabledField: 'memory_enabled', enabledDefault: false,
fields: [
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
@@ -113,6 +114,17 @@ export const immichDescriptor: ProviderDescriptor = {
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
},
// Periodic summaries / scheduled picks / memories / quiet hours all live on
// the linked tracking & template configs — surface that connection on the
// tracker form so users don't need to read docs to find them.
featureDiscoveryHint: {
messageKey: 'notificationTracker.featureDiscovery',
ctas: [
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
],
},
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
if (newIds.length === 0) return { proceed: true };
+4
View File
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
import { nutDescriptor } from './nut';
import { googlePhotosDescriptor } from './google-photos';
import { webhookDescriptor } from './webhook';
import { homeAssistantDescriptor } from './home-assistant';
import { bridgeSelfDescriptor } from './bridge-self';
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['immich', immichDescriptor],
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['nut', nutDescriptor],
['google_photos', googlePhotosDescriptor],
['webhook', webhookDescriptor],
['home_assistant', homeAssistantDescriptor],
['bridge_self', bridgeSelfDescriptor],
]);
/** Look up a provider descriptor by type. Returns null for unknown types. */
+62 -4
View File
@@ -20,7 +20,7 @@ export interface ConfigField {
configKey?: string;
/** i18n key for the field label. */
label: string;
type: 'text' | 'password' | 'number' | 'grid-select';
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
@@ -67,7 +67,8 @@ export interface ExtraTrackingField {
* - `toggle` — on/off switch
* - `date` — HTML date picker (YYYY-MM-DD)
* - `time` — HTML time picker (HH:MM)
* - `time-list` — comma-separated HH:MM list, validated on blur
* - `time-list` — add/remove list of HH:MM pickers (TimeListEditor),
* serialized as a comma-separated string
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */
@@ -78,8 +79,6 @@ export interface ExtraTrackingField {
inlineHelp?: string;
min?: number;
max?: number;
/** For time-list: show live validation + auto-normalize on blur. */
validateFormat?: boolean;
/**
* Default value. Can be a function for dynamic values (e.g. today's date)
* evaluated each time the form is reset.
@@ -120,6 +119,38 @@ export interface CollectionMeta {
desc: (col: any) => string;
}
// ── User-identity filters (TrackerForm) ──────────────────────────────
/**
* Declares a filter rendered on the tracker form. Two input modes:
*
* * ``picker`` (default) — populated from the provider's ``/users``
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
* allowlists / blocklists where the valid values are known.
* * ``tags`` — free-text chip input. Used for glob patterns and other
* filter values that aren't enumerable in advance.
*
* Either way the picked values are stored as ``string[]`` under
* ``tracker.filters[filterKey ?? key]``.
*/
export interface UserFilterMeta {
/** Form field key — used internally for binding. */
key: string;
/**
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
* omitted (backward compat with the original sender allowlist usage).
*/
filterKey?: string;
/** ``picker`` (default) or ``tags`` for free-text chip input. */
inputMode?: 'picker' | 'tags';
/** i18n key for the label rendered above the input. */
label: string;
/** i18n key for the placeholder (picker dropdown or chip input). */
placeholder: string;
/** MDI icon shown on chips and dropdown rows. */
icon: string;
}
// ── Main descriptor ──────────────────────────────────────────────────
export interface ProviderDescriptor {
@@ -153,6 +184,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;
@@ -162,6 +195,31 @@ export interface ProviderDescriptor {
/** Whether this provider stores incoming payload history for debugging. */
payloadHistory?: boolean;
// ── Capability flags ──
/**
* True when the provider exposes asset/people/album endpoints that the
* Auto-Organize action rule editor needs to render its people / album
* pickers (currently only Immich). Used in place of `type === 'immich'`
* checks per CLAUDE.md rule 8.
*/
supportsAutoOrganize?: boolean;
// ── Tracker-form discovery hint ──
/**
* Optional info banner shown on the TrackerForm to point users at related
* configuration pages they would otherwise have to discover from docs.
*
* The hint is rendered as a single i18n message followed by zero or more
* call-to-action links. ``ctas[].href`` may include ``{tracking_config_id}``
* / ``{template_config_id}`` placeholders that the form substitutes from
* the tracker's currently selected default-config IDs (or omits the
* ``?edit=...`` query when the value is 0).
*/
featureDiscoveryHint?: {
messageKey: string;
ctas?: Array<{ href: string; labelKey: string; icon?: string }>;
};
// ── Provider-specific hooks ──
/**
* Called after collection selection changes (before save).
+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();
}
+19 -1
View File
@@ -16,8 +16,19 @@ const DEFAULT_TTL_MS = 30_000; // 30 seconds
export interface EntityCache<T extends { id: number }> {
/** Reactive list of cached entities. */
readonly items: T[];
/** True only during the very first fetch (no cached data yet). */
/**
* True only during the very first fetch — when there is no cached data
* to show yet. Background re-fetches keep `loading` false so consumers
* keep rendering the previous list and don't flash a spinner; observe
* `refreshing` instead if a subtle indicator is needed.
*/
readonly loading: boolean;
/**
* True during any non-first fetch (cached items already populated).
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
* shimmer/disabled state" (refreshing) without sharing one flag.
*/
readonly refreshing: boolean;
/** Timestamp of last successful fetch. */
readonly fetchedAt: number;
/** Fetch entities — returns cached data if fresh, else hits network. */
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
): EntityCache<T> {
let _items = $state<T[]>([]);
let _loading = $state(false);
let _refreshing = $state(false);
let _fetchedAt = $state(0);
function isFresh(): boolean {
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
const existing = inflightRequests.get(endpoint);
if (existing) return existing;
// First-load vs background-refresh state. We split these so consumers
// can keep the previous list visible during a re-fetch (refreshing)
// instead of flashing a spinner placeholder (loading).
const isFirstLoad = _fetchedAt === 0;
if (isFirstLoad) _loading = true;
else _refreshing = true;
const request = api<T[]>(endpoint)
.then((data) => {
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
})
.finally(() => {
_loading = false;
_refreshing = false;
inflightRequests.delete(endpoint);
});
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
return {
get items() { return _items; },
get loading() { return _loading; },
get refreshing() { return _refreshing; },
get fetchedAt() { return _fetchedAt; },
fetch,
invalidate,
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
loadFromStorage();
export const globalProviderFilter = {
get id() {
// If providers are loaded and the stored ID doesn't match any, auto-clear
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
globalProviderFilter.clear();
return null;
}
return _providerId;
},
/**
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
* reconciliation against `providersCache` is the responsibility of a
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
* because writing during read inside a `$state`-derived getter triggers
* Svelte 5's `state_unsafe_mutation` warning.
*/
get id() { return _providerId; },
get initialized() { return _initialized; },
set(id: number | null) {
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
this.set(null);
},
/**
* Drop the stored provider ID if it no longer matches any item in the
* providers cache. Safe to call from a `$effect` after the cache has been
* fetched. Returns true when reconciliation actually changed state, so the
* caller can short-circuit follow-up work.
*/
reconcileWithCache(): boolean {
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
this.clear();
return true;
}
return false;
},
/** The currently selected provider object (reactive). */
get provider() {
const id = this.id; // triggers stale-ID auto-clear
const id = _providerId;
if (id == null) return null;
return providersCache.items.find(p => p.id === id) ?? null;
},
+103 -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,83 @@ 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 DispatchSummaryError {
index: number;
error: string;
}
export interface DispatchSummaryMediaError {
target_index: number;
kind?: string;
chunk?: number;
item_index?: number;
error?: string;
code?: number;
}
export interface DispatchSummary {
targets_attempted: number;
targets_succeeded: number;
targets_failed: number;
errors?: DispatchSummaryError[];
errors_truncated?: number;
media?: {
delivered: number;
skipped: number;
failed: number;
};
media_errors?: DispatchSummaryMediaError[];
media_errors_truncated?: number;
}
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;
dispatch_id?: string;
request_id?: string;
dispatch_summary?: DispatchSummary;
};
created_at: string;
}
@@ -336,4 +404,38 @@ export interface DashboardStatus {
total_events: number;
recent_events: EventLog[];
command_trackers?: number;
/** Provider name → total event count across ALL events matching the
* current filters (ignores pagination). Powers the "On watch" deck
* when the user opts out of page-scoped stats. */
provider_event_counts?: Record<string, 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;
}
+124 -37
View File
@@ -5,7 +5,7 @@
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api';
import { api, errMsg } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -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,29 +31,55 @@
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 })),
]);
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
let _syncingFilter = false;
// One-way: the store is the source of truth, the filter widget displays it.
// IconGridSelect mutations route through `onChange` (see template) so we
// never need a paired `$effect` to mirror the local <-> store value, which
// previously required a `_syncingFilter` reentrancy flag.
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
// Sync filter value → store
// 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);
// Reconcile a stale persisted provider ID against the freshly-loaded
// providers cache. Lives here (not in the store getter) because writing
// `_providerId` from a `$state`-derived getter triggers Svelte's
// `state_unsafe_mutation`. Runs once per cache refresh.
$effect(() => {
const v = providerFilterValue;
if (_syncingFilter) return;
globalProviderFilter.set(v === 0 ? null : v);
// Track `fetchedAt` so we re-run after the cache loads.
void providersCache.fetchedAt;
void providersCache.items.length;
globalProviderFilter.reconcileWithCache();
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
_syncingFilter = true;
providerFilterValue = 0;
_syncingFilter = false;
}
});
function setProviderFilter(v: string | number) {
const num = typeof v === 'number' ? v : Number(v);
globalProviderFilter.set(num === 0 ? null : num);
}
// Collapsed-rail filter cycles through providers via the same setter so the
// store stays the single write path.
function cycleProviderFilter() {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
setProviderFilter(ids[(idx + 1) % ids.length]);
}
let showPasswordForm = $state(false);
let redirecting = $state(false);
@@ -75,10 +101,27 @@
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
}
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 +259,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 +305,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 +327,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -384,7 +423,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,22 +450,20 @@
</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={() => {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
providerFilterValue = ids[(idx + 1) % ids.length];
}}
<button onclick={cycleProviderFilter}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
{/if}
</div>
{/if}
@@ -449,6 +499,7 @@
<a
href={child.href}
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
aria-current={isActive(child.href) ? 'page' : undefined}
>
{#if isActive(child.href)}
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
@@ -468,6 +519,7 @@
href={entry.href}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
title={collapsed ? t(entry.key) : ''}
aria-current={isActive(entry.href) ? 'page' : undefined}
>
{#if isActive(entry.href)}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
@@ -551,6 +603,7 @@
<NavIcon name="mdiMagnify" size={20} />
</button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
aria-expanded={mobileMoreOpen}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<NavIcon name="mdiDotsHorizontal" size={20} />
@@ -565,7 +618,7 @@
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="space-y-3">
@@ -753,6 +806,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;
+310 -31
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, providerStatsModeItems, 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,77 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// "On watch" provider deck stats scope. ``'page'`` = derive counts from
// the events visible on the current page (legacy behavior); ``'all'`` =
// use the server-aggregated ``provider_event_counts`` map covering every
// event that matches the active filters.
const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode';
function loadProviderStatsMode(): string {
if (typeof localStorage === 'undefined') return 'page';
const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY);
return stored === 'all' ? 'all' : 'page';
}
// 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 providerStatsMode = $state(loadProviderStatsMode());
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));
});
// Persist the provider deck stats mode the same way.
let _providerStatsHydrated = false;
$effect(() => {
const v = providerStatsMode;
if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v);
});
async function clearEvents() {
try {
@@ -117,22 +187,54 @@
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,
provider_event_counts: next.provider_event_counts,
};
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,14 +304,36 @@
}
}
// 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);
// === Provider deck — derive activity counts from recent events ===
//
// ``providerStatsMode`` controls the scope: ``'page'`` derives counts
// from the visible page (legacy), ``'all'`` uses the server-aggregated
// ``provider_event_counts`` map covering every event under the active
// filters regardless of pagination.
const providerEventCounts = $derived.by(() => {
const counts = new Map<string, number>();
if (!status) return counts;
if (providerStatsMode === 'all' && status.provider_event_counts) {
for (const [name, total] of Object.entries(status.provider_event_counts)) {
if (!name) continue;
counts.set(name, total);
}
return counts;
}
for (const ev of status.recent_events) {
const k = ev.provider_name || '';
if (!k) continue;
@@ -320,6 +444,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 +482,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 +492,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 +506,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 +568,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 +583,7 @@
</div>
</div>
</div>
</div>
</a>
{/snippet}
{#snippet statCards()}
@@ -452,6 +596,7 @@
value: 0,
literalValue: globalProviderFilter.provider.name,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{:else}
{@render statCardSnippet({
@@ -459,6 +604,7 @@
label: 'dashboard.providers',
value: filteredProviderCount,
accent: STAT_ACCENTS[0],
href: '/providers',
}, 0)}
{/if}
{@render statCardSnippet({
@@ -467,12 +613,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 +628,7 @@
label: 'nav.commandTrackers',
value: displayCommandTrackers,
accent: STAT_ACCENTS[3],
href: '/command-trackers',
}, 3)}
{:else}
{@render statCardSnippet({
@@ -487,6 +636,8 @@
label: 'dashboard.eventsTotal',
value: heroSummary?.throughput ?? 0,
accent: STAT_ACCENTS[3],
href: '#events-section',
onclick: scrollToEvents,
}, 3)}
{/if}
</div>
@@ -496,7 +647,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 +683,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 +722,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 +757,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 +824,7 @@
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
</button>
{/each}
</div>
@@ -628,11 +845,18 @@
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
<div class="panel-head-actions">
<div class="w-32" title={t('dashboard.statsModeTitle')}>
<IconGridSelect items={providerStatsModeItems()}
bind:value={providerStatsMode}
columns={2} compact />
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</div>
</header>
{#if sectionExpanded.on_watch}
@@ -646,14 +870,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 +992,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style>
/* ============================================================
HERO
@@ -909,6 +1135,7 @@
============================================================ */
.stat-card {
position: relative;
display: block;
border-radius: 22px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
@@ -918,7 +1145,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 +1322,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 +1338,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 +1405,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 +1561,7 @@
.provider-meter {
text-align: right;
min-width: 80px;
padding: 4px 4px 4px 0;
}
.provider-num {
font-size: 1rem;
@@ -1295,7 +1575,6 @@
height: 4px;
border-radius: 2px;
background: var(--color-glass-strong);
overflow: hidden;
}
.provider-bar-fill {
height: 100%;
+92 -29
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -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);
@@ -86,8 +99,8 @@
capabilitiesCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('actions.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('actions.loadError'));
} finally { loaded = true; highlightFromUrl(); }
}
@@ -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;
}
@@ -123,7 +138,7 @@
}
showForm = false; editing = null; actionsCache.invalidate(); await load();
snackSuccess(t('actions.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -136,7 +151,7 @@
await api(`/actions/${id}`, { method: 'DELETE' });
actionsCache.invalidate(); await load();
snackSuccess(t('actions.deleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function executeAction(id: number, dryRun = false) {
@@ -150,7 +165,7 @@
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
snackSuccess(msg);
actionsCache.invalidate(); await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
executing = { ...executing, [id]: false };
}
@@ -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]} />
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -7,6 +7,7 @@
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers';
import type { ActionRule } from '$lib/types';
let { actionId, actionType, providerId }: { actionId: number; actionType: string; providerId: number } = $props();
@@ -47,14 +48,16 @@
loading = true;
try {
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
loading = false;
}
async function loadProviderData() {
if (actionType !== 'auto_organize') return;
const provider = providersCache.items.find((p: any) => p.id === providerId);
if (!provider || provider.type !== 'immich') return;
if (!provider) return;
const descriptor = getDescriptor(provider.type);
if (!descriptor?.supportsAutoOrganize) return;
try {
const [p, a] = await Promise.all([
api<any>(`/providers/${providerId}/people`),
@@ -79,7 +82,7 @@
resetNewRule();
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
submitting = false;
}
@@ -91,7 +94,7 @@
});
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function deleteRule(ruleId: number) {
@@ -99,7 +102,7 @@
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
await loadRules();
snackSuccess(t('actions.ruleDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleRule(rule: ActionRule) {
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,7 @@
emailBotsCache.fetch(true),
matrixBotsCache.fetch(true),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
</script>
+52 -17
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
@@ -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,8 +88,8 @@
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { emailSubmitting = false; }
}
@@ -65,10 +99,10 @@
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteEmail = null; }
}
@@ -81,7 +115,7 @@
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || t('emailBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
@@ -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" />
+50 -17
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -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,8 +84,8 @@
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { matrixSubmitting = false; }
}
@@ -63,10 +95,10 @@
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteMatrix = null; }
}
@@ -79,7 +111,7 @@
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || t('matrixBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
@@ -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" />
+230 -88
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
@@ -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,8 +104,8 @@
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
finally { submitting = false; }
}
@@ -71,10 +115,10 @@
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -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 };
} catch (err: unknown) { snackError(errMsg(err)); }
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
@@ -111,11 +156,15 @@
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
// `desc` is the only locale-sensitive field — language *names* are intentionally
// shown in their own language (English under EN, Русский under RU, …) so users
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
// for the no-override row is translated.
let LANG_ITEMS = $derived([
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
@@ -132,7 +181,7 @@
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
]);
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
@@ -144,7 +193,7 @@
c.id === chat.id ? { ...c, language_override: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleChatCommands(botId: number, chat: TelegramChat) {
@@ -157,7 +206,7 @@
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function loadListenerStatus(botId: number) {
@@ -187,7 +236,7 @@
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
),
};
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function syncCommands(botId: number) {
@@ -196,7 +245,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -209,7 +258,7 @@
await loadWebhookStatus(botId);
}
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -229,7 +278,7 @@
} else {
snackError(res.error || t('telegramBot.webhookFailed'));
}
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -239,7 +288,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -270,7 +319,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatTesting = { ...chatTesting, [key]: false };
}
@@ -289,7 +338,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 +355,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 +378,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 +398,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 +422,84 @@
<!-- 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
type="button"
role="switch"
aria-checked={chat.commands_enabled}
aria-label={t('telegramBot.commandsToggle')}
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}
@@ -450,6 +519,10 @@
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
</div>
<button
type="button"
role="switch"
aria-checked={trk.enabled}
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => toggleListenerEnabled(bot.id, trk)}>
@@ -553,3 +626,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>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -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}[]>(
@@ -95,10 +105,46 @@
commandTemplateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
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,8 +213,8 @@
await api('/command-configs', { method: 'POST', body });
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -158,10 +227,10 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
snackError(errMsg(err));
}
finally { confirmDelete = null; }
}
@@ -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>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
@@ -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 {
@@ -159,8 +213,8 @@
allCmdTplConfigs = cfgs;
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
} catch (err: unknown) {
error = errMsg(err, t('common.loadError'));
snackError(error);
} finally {
loaded = true;
@@ -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();
@@ -260,9 +354,10 @@
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
}
}
@@ -313,8 +408,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -332,7 +427,7 @@
};
editing = null;
showForm = true;
activeLocale = 'en';
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
@@ -350,11 +445,12 @@
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
} finally {
confirmDelete = null;
}
@@ -367,7 +463,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 +484,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 +506,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 +628,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" />
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t } from '$lib/i18n';
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
@@ -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(() => {
@@ -98,7 +107,7 @@
providersCache.fetch(), commandConfigsCache.fetch(),
telegramBotsCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -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,8 +167,8 @@
await api('/command-trackers', { method: 'POST', body });
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -149,7 +180,7 @@
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandTrackerDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { confirmDelete = null; }
}
};
@@ -162,7 +193,7 @@
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
toggling = { ...toggling, [trk.id]: false };
}
@@ -195,7 +226,7 @@
snackSuccess(t('snack.listenerAdded'));
await loadListeners(trkId);
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingListener = { ...addingListener, [trkId]: false };
}
@@ -204,7 +235,7 @@
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
snackSuccess(t('snack.listenerRemoved'));
await loadListeners(trkId);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-listener album scope editing
@@ -233,7 +264,7 @@
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
function providerName(id: number): string {
@@ -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,34 +368,37 @@
<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)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '' : ''}
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
</div>
@@ -413,7 +473,7 @@
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<span aria-hidden="true">В·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { login } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -37,7 +37,7 @@
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup');
} catch {
// The backend is unreachable surface that distinctly so the user
// The backend is unreachable — surface that distinctly so the user
// doesn't blame the login form for a network/backend problem.
backendDown = true;
}
@@ -50,8 +50,8 @@
try {
await login(username, password);
window.location.href = '/';
} catch (err: any) {
error = err.message || t('auth.loginFailed');
} catch (err: unknown) {
error = errMsg(err, t('auth.loginFailed'));
}
submitting = false;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api';
import { api, parseDate , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -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>>({});
@@ -156,8 +166,8 @@
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) {
loadError = err.message || t('common.loadFailed');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadFailed'));
snackError(loadError);
} finally { loaded = true; highlightFromUrl(); }
}
@@ -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) {
@@ -259,7 +289,7 @@
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
}
async function autoCreateLinks() {
@@ -271,8 +301,8 @@
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
} catch (err: unknown) {
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
}
}
}
@@ -294,7 +324,7 @@
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
@@ -305,7 +335,7 @@
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
confirmDelete = null;
}
@@ -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;
@@ -373,7 +451,7 @@
newLinkTemplateConfigId[trackerId] = 0;
await load();
snackSuccess(t('snack.targetLinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingTarget = { ...addingTarget, [trackerId]: false };
}
@@ -382,7 +460,7 @@
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
@@ -392,7 +470,7 @@
body: JSON.stringify({ [field]: value }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
@@ -414,8 +492,8 @@
} else {
snackSuccess(t('snack.targetTestSent'));
}
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
@@ -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)}
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -23,7 +23,7 @@
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place the
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
@@ -39,8 +39,8 @@
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} catch (err: unknown) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
} finally {
replacing = { ...replacing, [album.id]: false };
}
@@ -7,6 +7,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { getDescriptor } from '$lib/providers';
interface Props {
@@ -23,6 +24,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 +36,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,12 +55,40 @@
onsave,
ontoggleCollection,
formatDate,
onnameinput,
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(descriptor?.webhookBased ?? false);
let colMeta = $derived(descriptor?.collectionMeta);
// Providers without a collection (currently just the scheduler) drive the
// scheduler-specific form layout. Reading from the descriptor keeps the
// branch out of the component and lets a future provider opt in by
// declaring `collectionMeta: null`.
let isScheduler = $derived(colMeta == null);
let isWebhook = $derived(descriptor?.webhookBased ?? false);
/**
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
* descriptor-declared CTA href. When the corresponding form field is unset
* (value 0), strip the entire `?edit=...` query so the link still goes to
* the list page. Centralising this here avoids per-provider href logic
* leaking back into the template.
*/
function resolveHintHref(href: string): string {
const tcId = form.default_tracking_config_id;
const tplId = form.default_template_config_id;
if (href.includes('{tracking_config_id}')) {
return tcId
? href.replace('{tracking_config_id}', String(tcId))
: href.split('?')[0];
}
if (href.includes('{template_config_id}')) {
return tplId
? href.replace('{template_config_id}', String(tplId))
: href.split('?')[0];
}
return href;
}
// Custom variable management for scheduler
function addVariable() {
@@ -93,7 +125,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 +148,31 @@
</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)}
{@const filterKey = uf.filterKey ?? uf.key}
<div>
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
{#if uf.inputMode === 'tags'}
<TagInput
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
icon={uf.icon}
/>
{:else}
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
/>
{/if}
</div>
{/each}
{/if}
{#if isScheduler}
<!-- Schedule type -->
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
@@ -201,20 +258,27 @@
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
live on the tracking config, not on the tracker itself. The hint
content (message + CTAs) is declared on the provider descriptor so
each provider can surface its own discoverability links without
embedding `if (type === 'xyz')` here. -->
{#if descriptor?.featureDiscoveryHint}
{@const hint = descriptor.featureDiscoveryHint}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<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>
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
{#if hint.ctas && hint.ctas.length > 0}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
{#each hint.ctas as cta}
<a href={resolveHintHref(cta.href)}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
{t(cta.labelKey)}
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
+145 -32
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} 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,14 +162,15 @@
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
});
onDestroy(() => topbarAction.clear());
async function load() {
try {
await providersCache.fetch(true);
loadError = '';
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('providers.loadError'));
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
@@ -150,7 +237,7 @@
}
showForm = false; editing = null; providersCache.invalidate(); await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -161,10 +248,10 @@
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
}
</script>
@@ -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">
@@ -234,6 +321,11 @@
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || (field.required === 'create-only' && !editing)}
@@ -246,9 +338,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 +359,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 +376,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>
@@ -107,6 +107,11 @@
{:else if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || field.required === 'create-only'}
+179 -194
View File
@@ -2,17 +2,20 @@
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 DiagnosticsCassette from './DiagnosticsCassette.svelte';
import SaveBar from './SaveBar.svelte';
interface CacheBucketStats {
count: number;
@@ -25,12 +28,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 +55,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 +94,153 @@
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}
/>
<DiagnosticsCassette />
</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,424 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface ActiveOverride {
module: string;
baseline_level: string;
current_level: string;
activated_at: string;
expires_at: string;
remaining_seconds: number;
}
// Modules ship with shortcuts; users can also type a freeform name
// matching the backend allowlist (notify_bridge_*, sqlalchemy.*, etc.).
// Icons let the IconGridSelect render each entry as a visual chip
// instead of a bare text list — same pattern as the surrounding
// log-level / log-format selectors.
const QUICK_MODULES: { value: string; icon: string; label: string; desc?: string }[] = [
{ value: 'notify_bridge_core.notifications.telegram.client', icon: 'mdiSend', label: 'Telegram client' },
{ value: 'notify_bridge_core.notifications.dispatcher', icon: 'mdiCallSplit', label: 'Dispatcher' },
{ value: 'notify_bridge_core.providers.immich', icon: 'mdiImageMultiple', label: 'Immich provider' },
{ value: 'notify_bridge_server.services.watcher', icon: 'mdiEyeOutline', label: 'Watcher' },
{ value: 'notify_bridge_server.services.deferred_dispatch', icon: 'mdiClockOutline', label: 'Deferred dispatch' },
{ value: 'notify_bridge_server.services.scheduled_dispatch', icon: 'mdiCalendarClock', label: 'Scheduled dispatch' },
{ value: 'sqlalchemy.engine', icon: 'mdiDatabase', label: 'SQLAlchemy engine (SQL)' },
{ value: 'aiohttp.client', icon: 'mdiWeb', label: 'aiohttp client' },
];
const DURATION_PRESETS: { minutes: number; label: string }[] = [
{ minutes: 5, label: '5m' },
{ minutes: 15, label: '15m' },
{ minutes: 30, label: '30m' },
{ minutes: 60, label: '1h' },
{ minutes: 120, label: '2h' },
];
let active = $state<ActiveOverride[]>([]);
let pickedModule = $state(QUICK_MODULES[0].value);
let customModule = $state('');
let pickedMinutes = $state(30);
let submitting = $state(false);
let tickHandle: ReturnType<typeof setInterval> | null = null;
// Resync from the backend every N seconds so a server-side auto-revert
// is reflected even if we missed a tick. Tracked as elapsed-time so the
// 1s ticker can drift without breaking the cadence.
const RESYNC_EVERY_SECONDS = 30;
let lastResyncAt = Date.now();
async function refresh(): Promise<void> {
try {
const data = await api<{ active: ActiveOverride[] }>(
'/settings/diagnostic-mode',
{ method: 'GET' },
);
active = data.active || [];
} catch (err: unknown) {
// Surface non-401 errors only; settings page already shows a banner
// when the API is unreachable.
}
}
function tick(): void {
// Cheap local countdown so the UI doesn't poll the server every second
// to render a clock. The full refresh happens every 30s OR on action.
if (active.length === 0) return;
const now = Date.now();
active = active
.map(a => ({
...a,
remaining_seconds: Math.max(
0,
Math.floor((new Date(a.expires_at).getTime() - now) / 1000),
),
}))
.filter(a => a.remaining_seconds > 0);
}
function startTicker(): void {
if (tickHandle != null) return;
tickHandle = setInterval(() => {
tick();
const now = Date.now();
if (now - lastResyncAt >= RESYNC_EVERY_SECONDS * 1000) {
lastResyncAt = now;
void refresh();
}
}, 1000);
}
function stopTicker(): void {
if (tickHandle != null) {
clearInterval(tickHandle);
tickHandle = null;
}
}
onMount(() => {
lastResyncAt = Date.now();
void refresh();
startTicker();
});
onDestroy(() => {
stopTicker();
});
function effectiveModule(): string {
return (customModule.trim() || pickedModule).trim();
}
async function activate(): Promise<void> {
const mod = effectiveModule();
if (!mod) {
snackError(t('settings.diagModuleRequired'));
return;
}
submitting = true;
try {
const entry = await api<ActiveOverride>('/settings/diagnostic-mode', {
method: 'POST',
body: JSON.stringify({ module: mod, duration_minutes: pickedMinutes }),
});
// Replace any existing row for this module with the new schedule.
active = [
...active.filter(a => a.module !== entry.module),
entry,
];
customModule = '';
snackSuccess(t('settings.diagActivated'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
snackError(msg || t('settings.diagActivateFailed'));
} finally {
submitting = false;
}
}
async function revert(module: string): Promise<void> {
try {
await api(`/settings/diagnostic-mode/${encodeURIComponent(module)}`, {
method: 'DELETE',
});
active = active.filter(a => a.module !== module);
snackSuccess(t('settings.diagReverted'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
snackError(msg || t('settings.diagRevertFailed'));
}
}
function formatRemaining(seconds: number): string {
if (seconds <= 0) return '0s';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins >= 60) {
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hours}h ${remMins}m`;
}
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
</script>
<section class="diag glass">
<header class="diag-head">
<div class="diag-eyebrow">
<MdiIcon name="mdiBugOutline" size={12} />
<span>{t('settings.diagnostics')}</span>
</div>
<h3 class="diag-title">{t('settings.diagnosticsHeadline')}</h3>
<p class="diag-sub">{t('settings.diagnosticsHint')}</p>
</header>
<!-- Compose new override -->
<div class="diag-compose">
<div class="diag-label">
<span>{t('settings.diagModuleQuick')}</span>
<IconGridSelect items={QUICK_MODULES} bind:value={pickedModule} columns={2} compact />
</div>
<label class="diag-label">
<span>{t('settings.diagModuleCustom')}</span>
<input
bind:value={customModule}
type="text"
autocomplete="off"
spellcheck="false"
placeholder={t('settings.diagModuleCustomPlaceholder')}
class="diag-input"
/>
</label>
<div class="diag-label">
<span>{t('settings.diagDuration')}</span>
<div class="diag-duration-chips">
{#each DURATION_PRESETS as preset (preset.minutes)}
<button
type="button"
class="diag-chip"
class:diag-chip-active={pickedMinutes === preset.minutes}
onclick={() => (pickedMinutes = preset.minutes)}
>
{preset.label}
</button>
{/each}
</div>
</div>
<button
type="button"
onclick={activate}
disabled={submitting}
class="diag-activate"
>
<MdiIcon name="mdiPlay" size={14} />
<span>{submitting ? t('common.loading') : t('settings.diagActivate')}</span>
</button>
</div>
<!-- Active overrides list -->
{#if active.length > 0}
<div class="diag-active" in:slide={{ duration: 180 }}>
<div class="diag-active-head">
<MdiIcon name="mdiTimerSandComplete" size={12} />
<span>{t('settings.diagActive')}</span>
</div>
{#each active as ov (ov.module)}
<div class="diag-row">
<div class="diag-row-info">
<code class="diag-row-module">{ov.module}</code>
<span class="diag-row-meta">
{t('settings.diagRevertsIn')} <strong>{formatRemaining(ov.remaining_seconds)}</strong>
<span class="diag-row-baseline">{ov.baseline_level}</span>
</span>
</div>
<IconButton
icon="mdiUndoVariant"
title={t('settings.diagRevertNow')}
onclick={() => revert(ov.module)}
size={16}
/>
</div>
{/each}
</div>
{/if}
</section>
<style>
.diag {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.diag-head {
position: relative;
z-index: 1;
}
.diag-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.diag-title {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 38ch;
}
.diag-sub {
margin: 0.45rem 0 0 0;
font-size: 0.78rem;
color: var(--color-muted-foreground);
max-width: 56ch;
}
.diag-compose {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.7rem;
padding-top: 0.4rem;
border-top: 1px solid var(--color-border);
}
.diag-label {
display: flex;
flex-direction: column;
gap: 0.32rem;
}
.diag-label > span {
font-size: 0.74rem;
font-weight: 500;
color: var(--color-foreground);
}
.diag-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-glass);
color: var(--color-foreground);
}
.diag-duration-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.diag-chip {
padding: 0.32rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.72rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.diag-chip:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
.diag-chip-active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.diag-activate {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
align-self: flex-start;
padding: 0.55rem 1.1rem;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-family: var(--font-display);
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.diag-activate:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 65%, var(--color-border));
}
.diag-activate:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.diag-active {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-top: 0.55rem;
border-top: 1px solid var(--color-border);
}
.diag-active-head {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.diag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.5rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.diag-row-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.diag-row-module {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--color-foreground);
word-break: break-all;
}
.diag-row-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
}
.diag-row-baseline {
font-family: var(--font-mono);
font-size: 0.7rem;
margin-left: 0.4rem;
opacity: 0.7;
}
</style>
@@ -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>
+285 -423
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { api, fetchAuth , errMsg} 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,50 +77,50 @@
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;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
loaded = true;
}
});
async function cancelPending() {
async function cancelPending(): Promise<void> {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
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');
@@ -111,28 +132,28 @@
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
} catch (err: unknown) {
restartingOverlay = false;
snackError(err.message);
snackError(errMsg(err));
}
}
async function createManualBackup() {
async function createManualBackup(): Promise<void> {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
creatingBackup = false;
}
}
// --- Export ---
async function doExport() {
async function doExport(): Promise<void> {
if (exportSecrets === 'include') {
confirmExportOpen = true;
return;
@@ -140,7 +161,7 @@
await performExport();
}
async function performExport() {
async function performExport(): Promise<void> {
confirmExportOpen = false;
exporting = true;
try {
@@ -158,15 +179,21 @@
a.click();
URL.revokeObjectURL(url);
snackSuccess(t('backup.exportSuccess'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
exporting = false;
}
}
// --- 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;
@@ -176,19 +203,18 @@
formData.append('file', importFile);
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
validating = false;
}
}
// --- Import ---
async function doImport() {
function doImport(): void {
confirmImportOpen = true;
}
async function performImport() {
async function performImport(): Promise<void> {
confirmImportOpen = false;
if (!importFile) return;
importing = true;
@@ -205,42 +231,42 @@
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
importing = false;
}
}
// --- 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),
});
snackSuccess(t('backup.scheduleSaved'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
savingSchedule = false;
}
}
// --- File management ---
async function refreshFiles() {
async function refreshFiles(): Promise<void> {
loadingFiles = true;
try {
backupFiles = await api('/backup/files');
} catch (err: any) {
snackError(err.message);
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
loadingFiles = false;
}
}
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' });
@@ -250,370 +276,76 @@
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
async function deleteFile(filename: string) {
async function deleteFile(filename: string): Promise<void> {
try {
await api(`/backup/files/${filename}`, { method: 'DELETE' });
snackSuccess(t('backup.fileDeleted'));
confirmDeleteFile = '';
await refreshFiles();
} catch (err: any) {
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;
} catch (err: unknown) {
snackError(errMsg(err));
}
}
</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 +384,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 +417,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>
+2 -1
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { setup } from '$lib/auth.svelte';
import { errMsg } from '$lib/api';
import { t } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -25,7 +26,7 @@
try {
await setup(username, password);
window.location.href = '/';
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
submitting = false;
}
</script>
+482 -82
View File
@@ -1,7 +1,9 @@
<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 { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.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,8 +24,9 @@
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
// ──── Helpers ────
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -71,7 +74,7 @@
return recv.receiver_key || '?';
}
// ── Constants ──
// ──── Constants ────
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
@@ -92,7 +95,54 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
// ── Derived state ──
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);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
@@ -108,7 +158,7 @@
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
// ── Target form state ──
// ──── Target form state ────
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -116,7 +166,7 @@
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
disable_url_preview: true, send_large_photos_as_documents: false, send_large_videos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
@@ -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,12 +188,23 @@
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' });
}
// ── Receiver inline form state ──
// ──── Receiver inline form state ────
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
@@ -152,7 +214,21 @@
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// ── Effects ──
// 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
$effect(() => {
@@ -163,10 +239,102 @@
addingReceiverForTarget = null;
});
// ── Data loading ──
// ──── Data loading ────
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) {
@@ -187,8 +355,8 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('common.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadError'));
snackError(loadError);
} finally {
loaded = true;
@@ -204,7 +372,17 @@
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// ── Target CRUD ──
// 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() {
form = defaultForm();
@@ -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;
@@ -228,8 +407,8 @@
bot_id: c.bot_id || 0, bot_token: '',
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',
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false, send_large_videos_as_documents: c.send_large_videos_as_documents ?? false,
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,8 @@
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,
send_large_videos_as_documents: form.send_large_videos_as_documents,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
@@ -284,18 +465,21 @@
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;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
submitting = false;
}
@@ -306,7 +490,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -315,25 +499,38 @@
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
}
}
// ── Receiver CRUD ──
// ──── Receiver CRUD ────
function openReceiverForm(targetId: number, targetType: string) {
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
if (addingReceiverForTarget === targetId) {
addingReceiverForTarget = null;
await tick();
}
addingReceiverForTarget = targetId;
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') {
@@ -381,8 +578,8 @@
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
receiverSubmitting = false;
}
@@ -396,7 +593,7 @@
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function removeReceiver(targetId: number, receiverId: number) {
@@ -404,7 +601,64 @@
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-Telegram-receiver options panel: silent send + forum thread id.
// Edits the receiver's config dict in place via PUT.
let editingReceiverId = $state<number | null>(null);
// ``<input type="number">`` binds either a ``number`` or empty string
// when the field is blank — model both so TS strict mode and the save
// path's ``Number(raw)`` coercion agree.
let editingReceiverOptions = $state<{ disable_notification: boolean; message_thread_id: number | '' }>({
disable_notification: false,
message_thread_id: '',
});
function openEditReceiver(_targetId: number, receiver: TargetReceiver) {
editingReceiverId = receiver.id;
// Empty string maps to "no thread" — the form's <input type=number>
// produces '' for an empty field, which we normalize to null on save.
const raw = receiver.config?.message_thread_id;
const parsed = raw == null || raw === '' ? '' : Number(raw);
editingReceiverOptions = {
disable_notification: Boolean(receiver.config?.disable_notification),
message_thread_id: typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : '',
};
}
function cancelEditReceiver() {
editingReceiverId = null;
}
async function saveEditReceiver(targetId: number, receiverId: number) {
const target = allTargets.find(t => t.id === targetId);
const receiver = target?.receivers?.find(r => r.id === receiverId);
if (!receiver) return;
// Merge new options into the existing config so we don't lose the chat_id
// or any other receiver-specific keys (language_code on Telegram).
const newConfig: Record<string, any> = { ...receiver.config };
newConfig.disable_notification = editingReceiverOptions.disable_notification;
const raw = editingReceiverOptions.message_thread_id;
if (raw === '' || raw == null) {
delete newConfig.message_thread_id;
} else {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) {
newConfig.message_thread_id = Math.trunc(parsed);
} else {
delete newConfig.message_thread_id;
}
}
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, {
method: 'PUT',
body: JSON.stringify({ config: newConfig }),
});
editingReceiverId = null;
await load();
snackSuccess(t('targets.telegramOptionsSaved'));
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
@@ -419,7 +673,7 @@
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testReceiver(targetId: number, receiverId: number) {
@@ -428,7 +682,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -437,7 +691,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 +728,7 @@
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
@@ -493,53 +748,90 @@
<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}
{editingReceiverId}
bind:editingReceiverOptions
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}
onopenEditReceiver={openEditReceiver}
oncancelEditReceiver={cancelEditReceiver}
onsaveEditReceiver={saveEditReceiver}
/>
</div>
{/if}
</Card>
{/each}
</div>
</section>
{/each}
</div>
{/if}
@@ -561,3 +853,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>
@@ -16,6 +16,12 @@
receiverBotChats: Record<number, TelegramChat[]>;
receiverTesting: Record<number, boolean>;
receiverLabel: (target: NotificationTarget, recv: TargetReceiver) => string;
// Telegram-only editing state. Optional so a future caller that
// reuses this component for a non-Telegram target page doesn't have
// to pass dead props; the cog button only renders when both the
// target type matches AND the handlers are wired.
editingReceiverId?: number | null;
editingReceiverOptions?: Record<string, any>;
onopenReceiverForm: (targetId: number, targetType: string) => void;
onsaveReceiver: (targetId: number) => void;
oncancelReceiver: () => void;
@@ -25,6 +31,9 @@
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
onopenEditReceiver?: (targetId: number, receiver: TargetReceiver) => void;
oncancelEditReceiver?: () => void;
onsaveEditReceiver?: (targetId: number, receiverId: number) => void;
}
let {
@@ -37,6 +46,8 @@
receiverBotChats,
receiverTesting,
receiverLabel,
editingReceiverId,
editingReceiverOptions = $bindable(),
onopenReceiverForm,
onsaveReceiver,
oncancelReceiver,
@@ -46,6 +57,9 @@
onloadBotChats,
onchangeReceiverForm,
ontoggleBroadcastChild,
onopenEditReceiver,
oncancelEditReceiver,
onsaveEditReceiver,
}: Props = $props();
</script>
@@ -92,11 +106,25 @@
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
{#if target.type === 'telegram' && recv.config?.disable_notification}
<MdiIcon name="mdiBellOff" size={12} />
{/if}
{#if target.type === 'telegram' && recv.config?.message_thread_id != null && recv.config?.message_thread_id !== ''}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]" title={t('targets.telegramThreadId')}>#{recv.config.message_thread_id}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => ontestReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
{#if target.type === 'telegram' && onopenEditReceiver != null}
<IconButton
icon="mdiCog"
title={t('targets.telegramOptions')}
onclick={() => onopenEditReceiver!(target.id, recv)}
size={16}
/>
{/if}
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
@@ -112,36 +140,64 @@
/>
</div>
</div>
{#if target.type === 'telegram' && editingReceiverId === recv.id && editingReceiverOptions != null && onsaveEditReceiver != null && oncancelEditReceiver != null}
<div in:slide={{ duration: 150 }} class="mb-2 ml-6 mr-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
<label class="flex items-center gap-2 text-sm mb-2 cursor-pointer">
<input type="checkbox" bind:checked={editingReceiverOptions.disable_notification} />
<span>{t('targets.telegramDisableNotification')}</span>
</label>
<label class="flex flex-col gap-1 text-sm mb-2">
<span>{t('targets.telegramThreadId')}</span>
<input type="number" min="1" inputmode="numeric"
bind:value={editingReceiverOptions.message_thread_id}
placeholder={t('targets.telegramThreadIdPlaceholder')}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</label>
<div class="flex gap-2">
<button type="button" onclick={() => onsaveEditReceiver!(target.id, recv.id)}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90">
{t('common.save')}
</button>
<button type="button" onclick={oncancelEditReceiver}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{/if}
{/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'}
@@ -23,6 +23,7 @@
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
send_large_videos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
@@ -49,6 +50,7 @@
showTelegramSettings: boolean;
onsave: (e: SubmitEvent) => void;
ontoggleTelegramSettings: () => void;
onnameinput?: () => void;
}
let {
@@ -70,6 +72,7 @@
showTelegramSettings = $bindable(),
onsave,
ontoggleTelegramSettings,
onnameinput,
}: Props = $props();
</script>
@@ -87,7 +90,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'}
@@ -129,6 +132,7 @@
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_videos_as_documents} /> {t('targets.sendLargeVideosAsDocuments')}</label>
</div>
{/if}
</div>
+127 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -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));
@@ -232,8 +261,26 @@
capabilitiesCache.fetch(),
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
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);
@@ -298,7 +347,7 @@
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
/**
@@ -355,8 +404,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -372,22 +421,61 @@
};
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 = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -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')}
@@ -534,7 +626,7 @@
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -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" />
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -26,6 +26,8 @@
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';
import TimeListEditor from '$lib/components/TimeListEditor.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -33,44 +35,12 @@
};
/**
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
* dispatch accepts. Matched on blur for time-list fields; invalid values
* are surfaced inline next to the input.
* Max distinct dispatch times per slot — mirrors the backend cap
* (`MAX_DISPATCH_TIMES` in services/time_list.py). The TimeListEditor
* disables "+ Add time" once this many rows are filled; the server enforces
* the same limit on write.
*/
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
/** Per-field error messages surfaced inline under time-list inputs. */
let timeListErrors = $state<Record<string, string>>({});
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
function normalizeTimeList(key: string) {
const raw = String(form[key] ?? '').trim();
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
if (!TIME_LIST_RE.test(raw)) {
// Try a lenient normalization: split on commas, zero-pad each part.
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
const fixed: string[] = [];
let ok = true;
for (const p of parts) {
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
if (!m) { ok = false; break; }
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
}
if (ok) {
form[key] = fixed.join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
return;
}
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
return;
}
// Canonicalise spacing.
form[key] = raw.split(',').map(s => s.trim()).join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
}
const MAX_DISPATCH_TIMES = 24;
/**
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
@@ -157,8 +127,8 @@
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} catch (err: unknown) {
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
} finally {
previewLoading = false;
}
@@ -190,6 +160,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({
@@ -208,7 +186,7 @@
});
async function load() {
try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
}
@@ -230,20 +208,65 @@
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;
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
// Descriptor-driven guard: an enabled feature section that uses a
// time-list must have at least one time, otherwise it saves but the
// scheduler creates no cron job and the slot silently never fires.
for (const section of descriptor?.featureSections ?? []) {
const timeField = section.fields.find((f) => f.type === 'time-list');
if (!timeField) continue;
if (form[section.enabledField] && !String(form[timeField.key] ?? '').trim()) {
const msg = t('trackingConfig.timesRequiredFor').replace('{slot}', t(section.legend));
error = msg; snackError(msg);
return;
}
}
try {
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.trackingConfigSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -252,10 +275,10 @@
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -267,7 +290,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 +311,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>
@@ -371,22 +394,24 @@
</label>
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: field.type === 'time-list' ? 'text'
: 'number'}
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
{:else if field.type === 'time-list'}
<TimeListEditor
value={String(form[field.key] ?? '')}
onchange={(v) => form[field.key] = v}
max={MAX_DISPATCH_TIMES} />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{#if hasError}
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
{:else}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: 'number'}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{/if}
</div>
@@ -439,25 +464,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>
+225 -200
View File
@@ -1,200 +1,225 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 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>
<div class="flex items-center gap-1">
<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)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
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();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
}
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={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<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>
<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)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
+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: {

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