Compare commits

...

13 Commits

Author SHA1 Message Date
alexei.dolgolyov dfd7329177 chore: release v0.8.0
Release / release (push) Successful in 1m55s
2026-05-12 03:01:06 +03:00
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00
alexei.dolgolyov bb5afcc222 docs: expand README with all providers, targets, bot commands, and smart actions 2026-05-11 22:21:51 +03:00
alexei.dolgolyov 4335036c22 docs: sync README deploy section with actual env vars
Fix CORS default (was incorrectly listed as `*`, which is rejected on
startup) and document the env vars exposed by config.py and
docker-compose.yml — proxy/SSRF, auth, logging, retention, and
integration settings. Sync the Docker Compose example with the
hardened compose file at the repo root.
2026-05-11 21:50:31 +03:00
alexei.dolgolyov 5d41a39406 chore: release v0.7.2
Release / release (push) Successful in 1m10s
2026-05-11 00:39:06 +03:00
alexei.dolgolyov 6229bf9b74 feat(frontend): redesign settings/common with Aurora cassettes
Splits the monolithic settings page into 6 focused glass components matching
the polish of the recently redesigned settings/backup page.

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

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

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

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

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

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

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

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

Tests
- New ``test_command_event_logging.py`` covers subject formatting,
  issuer normalization, the three event branches, and graceful failure
  when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
2026-05-07 22:22:41 +03:00
78 changed files with 11799 additions and 1064 deletions
+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.
+21 -20
View File
@@ -1,40 +1,41 @@
# v0.7.0 (2026-05-07)
Hardened notification stack with shared HTTP base, SSRF protections, secret redaction, and a bounded queue across every provider client; Settings logging selectors switched to icon grids; entity names autogenerate from the chosen type or provider across bots, targets, trackers, actions, and configs.
# v0.8.0 (2026-05-12)
## User-facing changes
### Features
- Settings: replace log level and log format dropdowns with icon-grid selectors carrying per-option icons and i18n descriptions ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- Forms: auto-generate entity names from the selected type/provider across bots, targets, notification trackers, command trackers, actions, and tracking/template/command/command-template configs — names update live until you manually edit, then your edit is preserved ([5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2))
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
### Reliability & Security
### Documentation
- Notification stack hardening: shared HTTP base, SSRF protections, secret redaction in error logs, and a bounded delivery queue across the dispatcher, receiver, and all provider clients (telegram, discord, email, matrix, ntfy, slack, webhook) ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
---
## Development / Internal
### Refactoring
### Architecture
- Notifications: extract shared `http_base`, `redact`, and SSRF helpers; refactor dispatcher, queue, receiver, and every provider client onto the new base ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
### Tests
- New coverage: SSRF hardening, secret redaction, HTTP base, bounded queue, dispatcher aggregation, Telegram media partitioning, email and matrix clients ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
### Tooling
- Document code-review-graph MCP usage in CLAUDE.md, register `.mcp.json`, and gitignore `.code-review-graph/` ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
## All Commits
---
<details>
<summary>Click to expand</summary>
<summary>All Commits</summary>
| Hash | Message | Author |
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
| [0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a) | feat: harden notification stack and switch logging selectors to icon grid | alexei.dolgolyov |
| [5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2) | feat(frontend): autogenerate entity names from type/provider | alexei.dolgolyov |
| Hash | Message | Author |
|------|---------|--------|
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | alexei.dolgolyov |
</details>
+16 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.6.1",
"version": "0.8.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
@@ -14,6 +14,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
@@ -607,6 +608,14 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"node_modules/@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
@@ -2887,6 +2896,11 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"dev": true
},
"@fontsource-variable/geist": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
},
"@fontsource/dm-sans": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -34,6 +34,7 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
+52 -6
View File
@@ -1,11 +1,17 @@
@import '@fontsource/geist-sans/300.css';
@import '@fontsource/geist-sans/400.css';
@import '@fontsource/geist-sans/500.css';
@import '@fontsource/geist-sans/600.css';
@import '@fontsource/geist-sans/700.css';
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
so RU and EN render in the same font instead of falling back to a
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
(latin-only) imports — see --font-sans below for the family rename. */
@import '@fontsource-variable/geist';
@import '@fontsource/geist-mono/400.css';
@import '@fontsource/geist-mono/500.css';
@import '@fontsource/geist-mono/600.css';
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
declarations so Russian text renders in Geist Mono instead of falling
back to Cascadia/Consolas. */
@import '@fontsource/geist-mono/cyrillic-400.css';
@import '@fontsource/geist-mono/cyrillic-500.css';
@import '@fontsource/geist-mono/cyrillic-600.css';
@import '@fontsource/newsreader/300-italic.css';
@import '@fontsource/newsreader/400.css';
@import '@fontsource/newsreader/400-italic.css';
@@ -68,7 +74,7 @@
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
/* Typography */
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
--font-display: 'Newsreader', ui-serif, Georgia, serif;
@@ -371,6 +377,46 @@ button:focus-visible, a:focus-visible {
.stagger-children > * {
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
}
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
Full-bleed rows that stretch to the main column width. Pair with .list-row
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
.list-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.list-row {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.list-row__identity {
min-width: 0;
flex: 0 0 auto;
max-width: 28rem;
}
@media (max-width: 1023px) {
.list-row__identity { flex: 1 1 auto; }
}
.list-row__actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* Secondary text under the name — visible only when meta-strip is hidden
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
.list-row__secondary {
display: block;
}
@media (min-width: 1024px) {
.list-row__secondary { display: none; }
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
+39 -19
View File
@@ -20,7 +20,10 @@
noneLabel = '—',
disabled = false,
size = 'default',
open = $bindable(false),
showTrigger = true,
onselect,
onclose,
}: {
items: EntityItem[];
value: string | number | null;
@@ -29,10 +32,12 @@
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
open?: boolean;
showTrigger?: boolean;
onselect?: (value: string | number | null) => void;
onclose?: () => void;
} = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>();
@@ -52,24 +57,37 @@
return [...result, ...matching];
});
// Focus input whenever the palette transitions to open (covers both internal
// trigger clicks and external programmatic opening via bind:open).
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
wasOpen = open;
});
function openPalette() {
if (disabled) return;
open = true;
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
// Called when the user dismisses the palette (overlay click or ESC).
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
function closePalette() {
open = false;
query = '';
onclose?.();
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
open = false;
query = '';
}
function handleKeydown(e: KeyboardEvent) {
@@ -106,21 +124,23 @@
});
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
{#if showTrigger}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
{/if}
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open}
@@ -0,0 +1,449 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import type { EventLog } from '$lib/types';
import { requestHighlight } from '$lib/highlight';
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
interface Props {
event: EventLog | null;
onclose: () => void;
}
let { event, onclose }: Props = $props();
// Retain the last non-null event so the modal body stays populated
// while the close transition plays after the parent clears `event`.
let displayEvent = $state<EventLog | null>(null);
$effect(() => {
if (event) displayEvent = event;
});
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
*
* Used by the deferred-dispatch lifecycle banner to render
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
* integer that the user has to mentally divide. Keeps two units so
* the magnitude reads correctly across hours-long quiet windows
* without becoming noisy for short ones. */
function humanDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
}
/** Render an absolute ISO timestamp as a future-relative string.
*
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
* deferred-until banner shouldn't show a relative offset once the
* window has already ended (a follow-up event_log row marks delivery).
*/
function timeFromNow(iso: string | undefined): string {
if (!iso) return '';
try {
const target = new Date(iso).getTime();
const diff = Math.floor((target - Date.now()) / 1000);
if (diff <= 0) return '';
return humanDuration(diff);
} catch {
return '';
}
}
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
if (!issuer) return '';
if (issuer.username) return '@' + issuer.username;
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
if (name) return name;
if (issuer.id) return 'id ' + issuer.id;
return '';
}
/** Navigate to a list page and highlight the specific entity card.
*
* The destination page calls ``highlightFromUrl()`` after data loads,
* which scrolls to and pulses the card with ``data-entity-id={id}``.
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
function openEntity(path: string, entityId: number | string | null | undefined) {
if (entityId != null) requestHighlight(entityId);
onclose();
goto(path);
}
const issuer = $derived(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuerText = $derived(issuerLabel(issuer));
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!displayEvent?.details) return '';
try {
return JSON.stringify(displayEvent.details, null, 2);
} catch {
return String(displayEvent.details);
}
});
</script>
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
{#if displayEvent}
<div class="event-detail">
<!-- Subject + verb -->
<div class="hero-row">
<MdiIcon name="mdiBell" size={18} />
<div>
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
<div class="hero-meta">
<span class="event-type">{displayEvent.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(displayEvent.created_at)}</span>
</div>
</div>
</div>
<!-- Dispatch lifecycle (only when the event went through the
quiet-hours defer path). Rendered ABOVE the provenance grid
because timing of delivery is more interesting than the
bot/tracker names when the event is held back. -->
{#if displayEvent.details?.dispatch_status === 'deferred'}
<section class="lifecycle lifecycle--deferred">
<MdiIcon name="mdiPauseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
<div class="lifecycle-detail">
{t('events.lifecycle.heldUntil')}
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
{#if timeFromNow(displayEvent.details.deferred_until)}
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
{/if}
</div>
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
<section class="lifecycle lifecycle--late">
<MdiIcon name="mdiClockCheckOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
{#if displayEvent.details.deferred_for_seconds != null}
<div class="lifecycle-detail">
{t('events.lifecycle.heldFor')}
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiCloseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiAlertCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiVolumeOff" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
</div>
</section>
{/if}
<!-- Provenance grid -->
<dl class="provenance">
{#if displayEvent.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{displayEvent.bot_name}</dd>
{/if}
{#if displayEvent.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{displayEvent.collection_id}</dd>
{/if}
{#if issuerText}
<dt>{t('events.issuer')}</dt>
<dd>
{issuerText}
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
</dd>
{/if}
{#if displayEvent.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{displayEvent.command_tracker_name}</dd>
{/if}
{#if displayEvent.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{displayEvent.tracker_name}</dd>
{/if}
{#if displayEvent.action_name}
<dt>{t('events.action')}</dt>
<dd>{displayEvent.action_name}</dd>
{/if}
{#if displayEvent.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{displayEvent.provider_name}</dd>
{/if}
{#if displayEvent.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{displayEvent.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<div class="actions">
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
{/if}
</div>
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
{#if detailsJson && detailsJson !== '{}'}
<details class="raw-details" open={isCommand}>
<summary>{t('events.rawDetails')}</summary>
<pre>{detailsJson}</pre>
</details>
{/if}
</div>
{/if}
</Modal>
<style>
.event-detail {
display: flex; flex-direction: column; gap: 1.1rem;
}
.hero-row {
display: flex; align-items: flex-start; gap: 0.75rem;
}
.hero-subject {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 500;
color: var(--color-foreground);
line-height: 1.3;
word-break: break-word;
}
.hero-meta {
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
display: flex; align-items: center; gap: 0.4rem;
}
.event-type {
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 0.35rem;
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
color: var(--color-foreground);
}
.dot { opacity: 0.5; }
.provenance {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.45rem 1rem;
margin: 0;
padding: 0.85rem 0.95rem;
border-radius: 0.7rem;
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.provenance dt {
color: var(--color-muted-foreground);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
}
.provenance dd {
margin: 0;
color: var(--color-foreground);
word-break: break-word;
}
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
.actions {
display: flex; flex-wrap: wrap; gap: 0.5rem;
}
.actions button {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.8rem;
font-size: 0.78rem;
color: var(--color-foreground);
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
border-radius: 0.55rem;
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.actions button:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
}
.raw-details summary {
font-size: 0.75rem;
color: var(--color-muted-foreground);
cursor: pointer;
user-select: none;
}
.raw-details summary:hover { color: var(--color-foreground); }
.raw-details pre {
margin: 0.55rem 0 0;
padding: 0.7rem 0.85rem;
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.5;
color: var(--color-foreground);
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
border-radius: 0.55rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.font-mono { font-family: var(--font-mono); }
/* Dispatch lifecycle banner — appears only when the event took the
* quiet-hours defer path. The three colour variants mirror the dashboard
* badge palette: primary glow for "held", success for "delivered late",
* muted/dim for "dropped" / "failed" / "suppressed".
*/
.lifecycle {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.75rem 0.95rem;
border-radius: 0.7rem;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.lifecycle-body {
display: flex; flex-direction: column; gap: 0.2rem;
flex: 1; min-width: 0;
}
.lifecycle-title {
font-weight: 600;
color: var(--color-foreground);
}
.lifecycle-detail {
color: var(--color-foreground);
}
.lifecycle-detail b {
font-family: var(--font-mono);
font-weight: 600;
}
.lifecycle-rel {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-left: 0.25rem;
}
.lifecycle-hint {
color: var(--color-muted-foreground);
font-size: 0.72rem;
}
.lifecycle-reason {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
word-break: break-all;
}
.lifecycle--deferred {
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.lifecycle--deferred :global(svg) {
color: var(--color-primary);
}
.lifecycle--late {
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
}
.lifecycle--late :global(svg) {
color: var(--color-success, #16a34a);
}
.lifecycle--dropped {
opacity: 0.92;
}
.lifecycle--dropped :global(svg) {
color: var(--color-muted-foreground);
}
</style>
@@ -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>
+15 -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"
+16
View File
@@ -108,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
];
// --- Sort filter (dashboard) ---
@@ -117,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Auto-refresh interval (dashboard events list) ---
//
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
// in routes/+page.svelte if you add or remove cadences.
export const refreshIntervalItems = (): GridItem[] => [
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
];
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
+196 -3
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Service notifications"
},
"crumbs": {
"routingNotification": "Routing · Notification",
"routingCommands": "Routing · Commands",
"routingTargets": "Routing · Targets",
"routingAutomation": "Routing · Automation",
"operatorsBots": "Operators · Bots",
"systemAccess": "System · Access",
"systemConfiguration": "System · Configuration",
"systemMaintenance": "System · Maintenance",
"serviceConnections": "Service · Connections"
},
"nav": {
"sectionOverview": "Overview",
"sectionRouting": "Routing",
@@ -87,6 +98,15 @@
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"commandHandled": "command handled",
"commandRateLimited": "rate limited",
"commandFailed": "command failed",
"autoRefreshTitle": "Auto-refresh interval for the events list",
"refreshOff": "Off",
"refresh10s": "10s",
"refresh30s": "30s",
"refresh60s": "1m",
"refresh5m": "5m",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -97,10 +117,22 @@
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"filterCommandHandled": "Command Handled",
"filterCommandRateLimited": "Rate Limited",
"filterCommandFailed": "Command Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"heldUntil": "held until",
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
"deliveredLate": "delivered late",
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
"deferredThenDropped": "dropped after defer",
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
"deferredThenFailed": "failed after defer",
"suppressedQuietHours": "suppressed (quiet hours)",
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
"asset": "asset",
"assets": "assets",
"eventActivity": "Event Activity",
@@ -141,6 +173,37 @@
"newTracker": "New tracker",
"eventsTotal": "Events"
},
"events": {
"detailTitle": "Event details",
"bot": "Bot",
"chat": "Chat",
"issuer": "Issued by",
"commandTracker": "Command tracker",
"tracker": "Tracker",
"action": "Action",
"provider": "Provider",
"assetsCount": "Assets",
"openProvider": "Open provider",
"openBot": "Open bot",
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details",
"lifecycle": {
"heldTitle": "Held by quiet hours",
"heldUntil": "Will dispatch at",
"heldFor": "Held for",
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
"inPrefix": "in",
"deliveredLateTitle": "Delivered after quiet hours",
"originalEvent": "Original event",
"droppedTitle": "Dropped after defer",
"failedTitle": "Failed after defer",
"reason": "Reason",
"suppressedTitle": "Suppressed by quiet hours",
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
}
},
"providers": {
"title": "Service",
"titleEmphasis": "providers",
@@ -313,6 +376,7 @@
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
@@ -420,13 +484,20 @@
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
"receiverDisabled": "Receiver disabled",
"groupNoBot": "No bot linked",
"groupDirect": "Direct delivery",
"groupBotMissing": "Unknown bot",
"target": "target",
"targetsLower": "targets",
"openBot": "Open bot"
},
"users": {
"titleEmphasis": "& access",
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"you": "you",
"addUser": "Add User",
"cancel": "Cancel",
"username": "Username",
@@ -789,7 +860,92 @@
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
"logLevels": "Per-Module Overrides",
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Settings saved"
"saved": "Settings saved",
"identity": "Identity",
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
"telegramHeadline": "Webhook authentication and media cache tuning",
"loggingHeadline": "Verbosity, output format, and per-module overrides",
"heroNoUrl": "External URL not set",
"heroNoLocales": "no locales",
"copy": "Copy",
"urlCopied": "URL copied",
"openExternal": "Open",
"show": "Show",
"hide": "Hide",
"secretSet": "Verified",
"secretUnset": "Not configured",
"cacheConfig": "Cache",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Max entries",
"cacheMaxFootnote": "per bucket (LRU)",
"hoursShort": "hrs",
"entriesShort": "max",
"ttlNoExpiry": "no expiry",
"cacheCapacity": "Cache capacity",
"cacheCapacityCap": "of {n} cap",
"logModulePlaceholder": "module.path",
"addOverride": "Add override",
"removeOverride": "Remove",
"editAsText": "Edit as text",
"editAsChips": "Edit as chips",
"logPreviewLabel": "ACTIVE",
"unsavedChanges": "Unsaved changes",
"unsaved": "UNSAVED",
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes",
"release": {
"eyebrow": "Releases",
"headline": "Stay current with upstream",
"provider": "Provider",
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
"comingSoon": "Coming soon",
"disabled": "Disabled",
"repository": "Repository",
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
"options": "Options",
"includePrereleases": "Include pre-releases",
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
"interval": "Check interval",
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
"intervalRange": "1168 hrs",
"hoursUnit": "hrs",
"testConnection": "Test connection",
"checkNow": "Check now",
"checkDone": "Release check complete",
"checkFailed": "Release check failed",
"testOk": "Provider reachable",
"testFailed": "Provider unreachable",
"testFound": "Provider returned",
"viewRelease": "View v{v} release",
"statusUpToDate": "You're up to date",
"statusUpdate": "Update available",
"statusDisabled": "Release checks disabled",
"statusError": "Last check failed",
"statusUnknown": "Not checked yet",
"heroAvailable": "available",
"updateAvailableTooltip": "v{v} available — open Settings",
"lastChecked": "Last checked",
"never": "never",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} d ago",
"error": {
"disabled": "Release checks are disabled",
"misconfigured": "Provider not fully configured",
"provider_changed": "Provider changed — awaiting next check",
"no_release_found": "No matching release found upstream",
"network_error": "Upstream unreachable",
"http_error": "Upstream returned an error",
"parse_error": "Upstream response could not be parsed",
"unsafe_url": "URL rejected by safety check",
"not_implemented": "Provider not implemented yet",
"unknown_error": "Unknown error",
"error": "Last check failed"
}
}
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -889,6 +1045,7 @@
"titleEmphasis": "configs",
"countLabel": "configs",
"title": "Command Configs",
"noCommandsForProvider": "No commands available for this provider type.",
"description": "Define command settings for Telegram bot interactions",
"newConfig": "New Config",
"name": "Name",
@@ -949,6 +1106,8 @@
"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)…",
@@ -1025,6 +1184,8 @@
"edit": "Edit",
"description": "Description",
"close": "Close",
"hide": "Hide",
"show": "Show",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
@@ -1149,6 +1310,14 @@
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"commandHandled": "Bot command served",
"commandRateLimited": "Bot command throttled",
"commandFailed": "Bot command crashed",
"refreshOff": "Auto-refresh disabled",
"refresh10s": "Refresh every 10 seconds",
"refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1331,6 +1500,30 @@
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
"restartingDescription": "The page will reload once the server is back online.",
"countLabel": "backups",
"scheduleOn": "Auto · every {h}h",
"scheduleOff": "Auto backup off",
"lastBackup": "Last {ago}",
"never": "no backups yet",
"totalSize": "{size} total",
"dropZone": "Drop a JSON backup here, or click to choose",
"dropZoneActive": "Release to load",
"changeFile": "Change file",
"catGroupIdentity": "Identity & Routing",
"catGroupNotif": "Notifications",
"catGroupCmd": "Commands",
"catGroupSystem": "System",
"stepCategories": "What to include",
"stepSecrets": "Secrets handling",
"stepDownload": "Download",
"stepFile": "Choose a file",
"stepValidate": "Validate contents",
"stepConflict": "On conflict",
"stepApply": "Apply",
"tagScheduled": "scheduled",
"tagManual": "manual",
"tagSecrets": "with secrets",
"validateFirst": "Validate the file first to enable import"
}
}
+196 -3
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge",
"tagline": "Уведомления о сервисах"
},
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": {
"sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация",
@@ -87,6 +98,15 @@
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"commandHandled": "команда обработана",
"commandRateLimited": "ограничение частоты",
"commandFailed": "команда упала",
"autoRefreshTitle": "Интервал авто-обновления списка событий",
"refreshOff": "Выкл",
"refresh10s": "10с",
"refresh30s": "30с",
"refresh60s": "1м",
"refresh5m": "5м",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -97,10 +117,22 @@
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"filterCommandHandled": "Команда обработана",
"filterCommandRateLimited": "Ограничение частоты",
"filterCommandFailed": "Команда упала",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"heldUntil": "ожидает до",
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
"deliveredLate": "доставлено позже",
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
"deferredThenDropped": "отброшено после задержки",
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
"deferredThenFailed": "ошибка после задержки",
"suppressedQuietHours": "подавлено (тихие часы)",
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
"asset": "файл",
"assets": "файлов",
"eventActivity": "Активность событий",
@@ -141,6 +173,37 @@
"newTracker": "Новый трекер",
"eventsTotal": "Событий"
},
"events": {
"detailTitle": "Детали события",
"bot": "Бот",
"chat": "Чат",
"issuer": "Отправитель",
"commandTracker": "Командный трекер",
"tracker": "Трекер",
"action": "Действие",
"provider": "Провайдер",
"assetsCount": "Файлов",
"openProvider": "Открыть провайдера",
"openBot": "Открыть бота",
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные",
"lifecycle": {
"heldTitle": "Задержано тихими часами",
"heldUntil": "Будет отправлено в",
"heldFor": "Задержано на",
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
"inPrefix": "через",
"deliveredLateTitle": "Доставлено после тихих часов",
"originalEvent": "Исходное событие",
"droppedTitle": "Отброшено после задержки",
"failedTitle": "Ошибка после задержки",
"reason": "Причина",
"suppressedTitle": "Подавлено тихими часами",
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
}
},
"providers": {
"title": "Сервисные",
"titleEmphasis": "провайдеры",
@@ -313,6 +376,7 @@
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
@@ -420,13 +484,20 @@
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
"receiverDisabled": "Получатель отключён",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
"target": "получатель",
"targetsLower": "получателей",
"openBot": "Открыть бота"
},
"users": {
"titleEmphasis": "и доступ",
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -789,7 +860,92 @@
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
"logLevels": "Переопределения по модулям",
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
"saved": "Настройки сохранены"
"saved": "Настройки сохранены",
"identity": "Идентификация",
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
"heroNoUrl": "Внешний URL не задан",
"heroNoLocales": "нет локалей",
"copy": "Копировать",
"urlCopied": "URL скопирован",
"openExternal": "Открыть",
"show": "Показать",
"hide": "Скрыть",
"secretSet": "Задан",
"secretUnset": "Не настроен",
"cacheConfig": "Кэш",
"cacheTtlShort": "TTL",
"cacheMaxShort": "Макс. записей",
"cacheMaxFootnote": "на корзину (LRU)",
"hoursShort": "ч",
"entriesShort": "макс",
"ttlNoExpiry": "без срока",
"cacheCapacity": "Заполненность кэша",
"cacheCapacityCap": "из {n}",
"logModulePlaceholder": "путь.модуля",
"addOverride": "Добавить",
"removeOverride": "Удалить",
"editAsText": "Редактировать как текст",
"editAsChips": "Редактировать как чипы",
"logPreviewLabel": "АКТИВНО",
"unsavedChanges": "Несохранённые изменения",
"unsaved": "НЕ СОХРАНЕНО",
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить",
"release": {
"eyebrow": "Релизы",
"headline": "Следите за обновлениями",
"provider": "Источник",
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
"comingSoon": "Скоро",
"disabled": "Отключено",
"repository": "Репозиторий",
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
"options": "Опции",
"includePrereleases": "Учитывать пре-релизы",
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
"interval": "Интервал проверки",
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
"intervalRange": "1168 ч",
"hoursUnit": "ч",
"testConnection": "Проверить связь",
"checkNow": "Проверить сейчас",
"checkDone": "Проверка релизов завершена",
"checkFailed": "Не удалось проверить релизы",
"testOk": "Источник доступен",
"testFailed": "Источник недоступен",
"testFound": "Найдена версия",
"viewRelease": "Открыть релиз v{v}",
"statusUpToDate": "Актуальная версия",
"statusUpdate": "Доступно обновление",
"statusDisabled": "Проверка релизов отключена",
"statusError": "Ошибка последней проверки",
"statusUnknown": "Ещё не проверялось",
"heroAvailable": "доступна",
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
"lastChecked": "Последняя проверка",
"never": "никогда",
"justNow": "только что",
"minutesAgo": "{n} мин назад",
"hoursAgo": "{n} ч назад",
"daysAgo": "{n} д назад",
"error": {
"disabled": "Проверка релизов отключена",
"misconfigured": "Источник настроен не полностью",
"provider_changed": "Источник изменён — ожидание следующей проверки",
"no_release_found": "Подходящий релиз на источнике не найден",
"network_error": "Источник недоступен",
"http_error": "Источник вернул ошибку",
"parse_error": "Не удалось разобрать ответ источника",
"unsafe_url": "URL отклонён проверкой безопасности",
"not_implemented": "Источник пока не реализован",
"unknown_error": "Неизвестная ошибка",
"error": "Ошибка последней проверки"
}
}
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -889,6 +1045,7 @@
"titleEmphasis": "конфигурации",
"countLabel": "конфигураций",
"title": "Конфигурации команд",
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
"description": "Настройки команд для взаимодействия с Telegram-ботами",
"newConfig": "Новая конфигурация",
"name": "Название",
@@ -949,6 +1106,8 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
@@ -1025,6 +1184,8 @@
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"hide": "Скрыть",
"show": "Показать",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
@@ -1149,6 +1310,14 @@
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"commandHandled": "Команда бота обработана",
"commandRateLimited": "Команда бота ограничена по частоте",
"commandFailed": "Команда бота вызвала ошибку",
"refreshOff": "Автообновление выключено",
"refresh10s": "Обновлять каждые 10 секунд",
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1331,6 +1500,30 @@
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
"countLabel": "бэкапов",
"scheduleOn": "Авто · каждые {h}ч",
"scheduleOff": "Авто-бэкап выключен",
"lastBackup": "Последний {ago}",
"never": "ещё нет бэкапов",
"totalSize": "всего {size}",
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
"dropZoneActive": "Отпустите для загрузки",
"changeFile": "Сменить файл",
"catGroupIdentity": "Идентичность и маршрутизация",
"catGroupNotif": "Уведомления",
"catGroupCmd": "Команды",
"catGroupSystem": "Система",
"stepCategories": "Что включить",
"stepSecrets": "Обработка секретов",
"stepDownload": "Скачать",
"stepFile": "Выберите файл",
"stepValidate": "Проверить содержимое",
"stepConflict": "При конфликте",
"stepApply": "Применить",
"tagScheduled": "по расписанию",
"tagManual": "вручную",
"tagSecrets": "с секретами",
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
}
}
+47
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. */
@@ -140,6 +141,46 @@ export const externalUrlCache = (() => {
};
})();
/** 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']);
@@ -192,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();
}
+66 -1
View File
@@ -212,16 +212,51 @@ export interface TemplateConfig {
created_at: string;
}
/**
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
* when a notification doesn't take the immediate-deliver happy path.
*
* * ``deferred`` — held back by quiet hours; ``deferred_until`` carries the
* UTC ISO datetime at which a drain job will fire.
* * ``delivered_after_quiet_hours`` — a drain successfully dispatched the
* originally-deferred event. ``original_event_log_id`` points back at the
* row from when the event was first detected.
* * ``deferred_then_dropped`` — drain time arrived but the link/target was
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
* * ``deferred_then_failed`` — drain dispatched but the target returned an
* error; ``reason`` carries the truncated provider error.
* * ``suppressed_quiet_hours_nondeferrable`` — wall-clock event type (e.g.
* ``scheduled_message``) caught by quiet hours, dropped on principle.
*/
export type DispatchStatus =
| 'deferred'
| 'delivered_after_quiet_hours'
| 'deferred_then_dropped'
| 'deferred_then_failed'
| 'suppressed_quiet_hours_nondeferrable';
export interface EventLog {
id: number;
event_type: string;
collection_id: string;
collection_name: string;
tracker_id?: number | null;
tracker_name: string;
provider_name: string;
provider_id: number | null;
action_id?: number | null;
action_name?: string;
command_tracker_id?: number | null;
command_tracker_name?: string;
telegram_bot_id?: number | null;
bot_name?: string;
assets_count: number;
details: Record<string, any>;
details: Record<string, any> & {
dispatch_status?: DispatchStatus;
deferred_until?: string;
original_event_log_id?: number | null;
deferred_for_seconds?: number;
};
created_at: string;
}
@@ -338,3 +373,33 @@ export interface DashboardStatus {
recent_events: EventLog[];
command_trackers?: number;
}
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
export interface ReleaseStatus {
provider: ReleaseProviderKind;
current: string;
latest: string | null;
latest_tag: string | null;
latest_url: string | null;
latest_name: string | null;
latest_body: string | null;
latest_published_at: string | null;
latest_prerelease: boolean;
checked_at: string | null;
update_available: boolean;
error: string | null;
}
export interface ReleaseTestResult {
ok: boolean;
info: {
tag: string;
version: string;
name: string | null;
url: string | null;
published_at: string | null;
prerelease: boolean;
} | null;
error: string | null;
}
+61 -2
View File
@@ -18,7 +18,7 @@
providersCache, notificationTrackersCache, trackingConfigsCache,
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
matrixBotsCache, targetsCache,
matrixBotsCache, targetsCache, releaseStatusCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
@@ -31,6 +31,17 @@
let allProviders = $derived(providersCache.items);
// Sidebar release indicator — reads from the cache populated in onMount.
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
// A screen reader hits the brand-version link on every page — keep the
// label informative only when an update is available, otherwise announce
// the version + product so we don't repeat "Up to date" everywhere.
const releaseTooltip = $derived(
releaseUpdateAvailable
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
: `Notify Bridge v${__APP_VERSION__}`
);
let providerFilterItems = $derived([
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
@@ -306,6 +317,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -401,7 +413,20 @@
{/if}
Notify Bridge
</h1>
<p class="brand-version font-mono">v{__APP_VERSION__}</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}
@@ -772,6 +797,40 @@
letter-spacing: 0.02em;
font-weight: 500;
}
.brand-version-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
text-decoration: none;
border-radius: 0.3rem;
padding: 1px 4px;
margin: -1px -4px;
transition: color 0.15s, background 0.15s;
}
.brand-version-link:hover {
color: var(--color-foreground);
background: var(--color-glass-strong);
}
.brand-version-link.has-update {
color: var(--color-citrus, #d4a73a);
}
.brand-version-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-citrus, #d4a73a);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
animation: brand-version-pulse 2.4s ease-in-out infinite;
}
@keyframes brand-version-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.brand-version-dot { animation: none; }
.brand-version-link { transition: none; }
}
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
+231 -18
View File
@@ -16,12 +16,13 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import type { DashboardStatus } from '$lib/types';
import type { DashboardStatus, EventLog } from '$lib/types';
const SECTIONS_KEY = 'dashboard_section_state';
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
@@ -75,10 +76,57 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
// this whitelist in sync with that helper so a stale localStorage
// value can't smuggle in an unsupported interval (e.g. someone
// hand-edits to 1).
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
function loadRefreshSeconds(): number {
if (typeof localStorage === 'undefined') return 0;
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
const v = stored ? parseInt(stored, 10) : 0;
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
}
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds());
let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade
// (~600ms of fade-up per row) which reads as the panel "reconstructing".
let eventsAnimated = $state(false);
// Auto-refresh ticker — re-creates the interval whenever the user
// changes the cadence. ``$effect`` returns a cleanup that fires on
// destroy AND on any tracked dep change, so the prior timer is torn
// down before a new one starts.
$effect(() => {
if (refreshSeconds <= 0) return;
// Pause auto-refresh when the tab is hidden so we don't burn API
// calls on a tab the user can't see — we'll catch up on the next
// visibility flip via ``visibilitychange`` below.
const tick = () => {
if (typeof document !== 'undefined' && document.hidden) return;
loadEvents({ silent: true });
loadChart();
};
const handle = setInterval(tick, refreshSeconds * 1000);
return () => clearInterval(handle);
});
// Persist whenever the cadence changes (the IconGridSelect mutates
// ``refreshSeconds`` directly via bind:value).
let _refreshHydrated = false;
$effect(() => {
const v = refreshSeconds;
if (!_refreshHydrated) { _refreshHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
});
async function clearEvents() {
try {
@@ -119,22 +167,53 @@
return params;
}
async function loadEvents() {
eventsLoading = true;
/** Reload the events panel.
*
* ``silent`` is set by the auto-refresh ticker so the loading
* placeholder doesn't flash and the row list isn't disturbed when
* nothing actually changed. We diff the new payload against the
* current ``status`` and reuse the existing ``recent_events`` array
* reference when the ID list is identical — that lets Svelte's keyed
* ``{#each}`` skip its diff entirely instead of patching every row.
*/
async function loadEvents(opts: { silent?: boolean } = {}) {
if (!opts.silent) eventsLoading = true;
try {
const params = buildFilterParams();
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
// Nothing changed in the visible page. Update only the
// out-of-band counts so the header and pager stay accurate;
// keep the existing array reference so no row re-renders.
status = {
...status,
providers: next.providers,
trackers: next.trackers,
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
};
return;
}
status = next;
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
eventsLoading = false;
if (!opts.silent) eventsLoading = false;
}
}
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
return true;
}
async function loadChart() {
try {
const params = buildFilterParams();
@@ -204,6 +283,16 @@
}
}
// Disable stagger entry animation once the first non-empty list has
// rendered + had time to play. Subsequent pagination/filter reloads
// then settle in place instead of re-running the cascade.
$effect(() => {
if (eventsAnimated) return;
if (!status?.recent_events?.length) return;
const handle = setTimeout(() => { eventsAnimated = true; }, 700);
return () => clearTimeout(handle);
});
const filteredProviderCount = $derived(globalProviderFilter.providerType
? providers.filter(p => p.type === globalProviderFilter.providerType).length
: displayProviders);
@@ -360,6 +449,9 @@
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
command_handled: 'dashboard.commandHandled',
command_rate_limited: 'dashboard.commandRateLimited',
command_failed: 'dashboard.commandFailed',
};
const eventIcons: Record<string, string> = {
@@ -367,6 +459,7 @@
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
};
// Aurora gradient palette per event type — used for the avatar tile
@@ -380,6 +473,9 @@
action_success: ['var(--color-mint)', 'var(--color-primary)'],
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
};
const STAT_ACCENTS = [
@@ -554,6 +650,11 @@
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
<IconGridSelect items={refreshIntervalItems()}
bind:value={refreshSeconds}
columns={5} compact />
</div>
</div>
{#snippet paginator()}
@@ -588,17 +689,25 @@
</div>
{/snippet}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else if status.recent_events.length === 0}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{#if status.recent_events.length === 0}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{/if}
{:else}
<div class="signal-list stagger-children">
{#each status.recent_events as event, i}
<div class="signal-row" style="animation-delay: {i * 60}ms;">
<div class="signal-list"
class:stagger-children={!eventsAnimated}
class:signal-list--reloading={eventsLoading}
aria-busy={eventsLoading}>
{#each status.recent_events as event, i (event.id)}
<button type="button" class="signal-row signal-row--clickable"
style={eventsAnimated ? '' : `animation-delay: ${i * 60}ms;`}
onclick={() => selectedEvent = event}
aria-label={t('events.detailTitle')}>
<div class="signal-avatar"
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
@@ -615,7 +724,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}
@@ -629,7 +791,7 @@
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
</button>
{/each}
</div>
@@ -790,6 +952,8 @@
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
<style>
/* ============================================================
HERO
@@ -1118,6 +1282,11 @@
SIGNAL STREAM — events with routing trail
============================================================ */
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
/* Soft dim while a page change / filter reload is in flight. We keep
the previous rows mounted (avoids the layout collapsing to a tiny
"Loading…" placeholder) and just nudge opacity so the swap feels
like a refresh rather than a teardown. */
.signal-list--reloading { opacity: 0.55; pointer-events: none; transition: opacity 0.15s ease; }
.signal-row {
display: grid;
grid-template-columns: 40px 1fr auto;
@@ -1129,6 +1298,20 @@
}
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
.signal-row:hover { background: var(--color-glass-strong); }
/* Row is rendered as <button> for clickability — strip default chrome
and align children left like the prior <div> layout. */
.signal-row--clickable {
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: transparent;
border: 0;
}
.signal-row--clickable:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.signal-avatar {
width: 40px; height: 40px;
border-radius: 12px;
@@ -1182,6 +1365,36 @@
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
* Coloured to match the verb (held = primary glow, late = success, drop
* = muted). The icon is intentionally small so the badge doesn't pull
* focus from the event verb itself. */
.dispatch-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
margin-left: 0.4rem;
white-space: nowrap;
}
.dispatch-badge--deferred {
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
}
.dispatch-badge--late {
color: var(--color-success, #16a34a);
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
}
.dispatch-badge--dropped {
color: var(--color-muted-foreground);
opacity: 0.85;
}
.signal-when {
text-align: right;
font-size: 0.7rem;
+71 -22
View File
@@ -22,6 +22,7 @@
import ExecutionHistory from './ExecutionHistory.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { Action, ActionRule } from '$lib/types';
let allActions = $derived(actionsCache.items);
@@ -193,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}
@@ -323,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]} />
+35 -9
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -39,6 +40,30 @@
}
});
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 = {
@@ -99,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')}
>
@@ -165,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}
@@ -182,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" />
+33 -9
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -38,6 +39,28 @@
}
});
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 = {
@@ -97,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')}
>
@@ -148,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" />
+44 -11
View File
@@ -16,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 }
@@ -60,6 +61,36 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
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; }
@@ -303,7 +334,7 @@
title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')}
crumb="Operators · Bots"
crumb={t('crumbs.operatorsBots')}
count={bots.length}
countLabel={t('telegramBot.countLabel')}
>
@@ -343,18 +374,19 @@
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
</div>
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: (bot.update_mode || 'none') === 'polling'
@@ -362,10 +394,11 @@
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<MetaStrip tiles={telegramBotTiles(bot)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
disabled={chatsLoading[bot.id]}
@@ -22,6 +22,7 @@
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 {
@@ -108,6 +109,42 @@
finally { loaded = true; highlightFromUrl(); }
}
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: cfg.provider_type,
tone: 'lavender',
mono: true,
});
const cmdCount = (cfg.enabled_commands || []).length;
tiles.push({
icon: 'mdiSlashForward',
value: String(cmdCount),
label: t('commandConfig.commands'),
tone: cmdCount > 0 ? 'mint' : 'coral',
});
tiles.push({
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
tone: 'sky',
});
tiles.push({
icon: 'mdiNumeric',
value: String(cfg.default_count),
label: t('commandConfig.defaultCount'),
tone: 'citrus',
});
if (cfg.command_template_config_id) {
tiles.push({
icon: 'mdiCodeBracesBox',
label: templateName(cfg.command_template_config_id),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
// Auto-select first provider type with commands
@@ -205,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}
@@ -316,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}
@@ -339,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>
@@ -27,6 +27,7 @@
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;
@@ -262,6 +263,44 @@
}
}
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);
@@ -422,7 +461,7 @@
title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands"
crumb={t('crumbs.routingCommands')}
count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills}
@@ -587,25 +626,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -21,6 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
@@ -272,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}
@@ -341,29 +368,32 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each trackers as trk}
<Card hover entityId={trk.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
{#if trk.listener_count !== undefined}
<span class="text-xs text-[var(--color-muted-foreground)]">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandTrackerTiles(trk)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
@@ -22,6 +22,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
@@ -374,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;
@@ -468,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}
@@ -528,27 +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)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(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)}
@@ -227,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
</div>
</div>
{/if}
+100 -30
View File
@@ -25,6 +25,7 @@
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';
@@ -52,6 +53,67 @@
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();
@@ -198,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}
@@ -222,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">
@@ -292,9 +354,11 @@
{/if}
{#if !showForm && allProviders.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="list-stack mb-3">
<div class="flex items-center gap-2">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{/if}
@@ -307,37 +371,43 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
</div>
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
<div class="list-row__secondary">
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@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>
<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>
+175 -187
View File
@@ -2,20 +2,19 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
import SettingsHero from './SettingsHero.svelte';
import IdentityCassette from './IdentityCassette.svelte';
import TelegramCassette from './TelegramCassette.svelte';
import ReleaseCassette from './ReleaseCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import SaveBar from './SaveBar.svelte';
interface CacheBucketStats {
count: number;
@@ -28,12 +27,24 @@
asset: CacheBucketStats;
}
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state({
interface Settings {
external_url: string;
telegram_webhook_secret: string;
telegram_cache_ttl_hours: string;
telegram_asset_cache_max_entries: string;
supported_locales: string;
timezone: string;
log_level: string;
log_format: string;
log_levels: string;
release_provider_kind: string;
release_provider_url: string;
release_provider_repo: string;
release_include_prereleases: string;
release_check_interval_hours: string;
}
const EMPTY: Settings = {
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '720',
@@ -43,10 +54,38 @@
log_level: 'INFO',
log_format: 'text',
log_levels: '',
});
release_provider_kind: 'gitea',
release_provider_url: 'https://git.dolgolyov-family.by',
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
release_include_prereleases: '0',
release_check_interval_hours: '12',
};
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state<Settings>({ ...EMPTY });
// Snapshot of the last server-known state, used for dirty tracking.
let baseline = $state<Settings>({ ...EMPTY });
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
// --- Dirty tracking -----------------------------------------------------
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
const out: Array<keyof Settings> = [];
for (const key of Object.keys(settings) as Array<keyof Settings>) {
if (settings[key] !== baseline[key]) out.push(key);
}
return out;
});
const dirty = $derived(dirtyKeys.length > 0);
// --- Data loading -------------------------------------------------------
async function loadCacheStats(): Promise<void> {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
@@ -54,202 +93,151 @@
onMount(async () => {
try {
settings = await api('/settings');
const fetched = await api<Settings>('/settings');
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; }
// Warm the release status so the cassette renders the strip on first paint.
await releaseStatusCache.fetch();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
snackError(msg);
} finally {
loaded = true;
}
});
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
// --- Actions ------------------------------------------------------------
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
async function save(): Promise<void> {
saving = true;
error = '';
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
const next = await api<Settings>('/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
settings = { ...EMPTY, ...next };
baseline = { ...settings };
externalUrlCache.invalidate();
// Release config may have changed → drop the cached status and
// refetch so the sidebar badge + cassette strip reflect the
// freshly-rescheduled probe without waiting for the next route
// change to trigger another read.
releaseStatusCache.invalidate();
void releaseStatusCache.fetch(true);
snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
error = msg;
snackError(msg);
} finally {
saving = false;
}
}
async function clearTelegramCache() {
function discard(): void {
settings = { ...baseline };
}
async function clearTelegramCache(): Promise<void> {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Clear cache failed';
snackError(msg);
} finally {
clearingCache = false;
}
}
const cacheMaxEntriesNum = $derived(
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
);
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb="System · Configuration"
/>
<SettingsHero {settings} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
<div class="space-y-6">
<!-- General section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiCog" size={18} />
{t('settings.general')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div>
</Card>
<!-- Telegram section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiSend" size={18} />
{t('settings.telegram')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div>
</Card>
<div class="settings-page stagger-children">
<IdentityCassette
bind:externalUrl={settings.external_url}
bind:timezone={settings.timezone}
bind:supportedLocales={settings.supported_locales}
/>
<!-- Locales section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTranslate" size={18} />
{t('settings.locales')}
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<LocaleSelector bind:value={settings.supported_locales} />
</div>
</div>
</Card>
<div class="telegram-deck">
<TelegramCassette
bind:webhookSecret={settings.telegram_webhook_secret}
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
/>
<CacheLedger
stats={cacheStats}
clearing={clearingCache}
maxEntries={cacheMaxEntriesNum}
onRefresh={loadCacheStats}
onClear={() => (confirmClearCache = true)}
/>
</div>
<!-- Logging section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiTextBoxOutline" size={18} />
{t('settings.logging')}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
<input bind:value={settings.log_levels}
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
</div>
</Card>
<ReleaseCassette
bind:providerKind={settings.release_provider_kind}
bind:providerUrl={settings.release_provider_url}
bind:providerRepo={settings.release_provider_repo}
bind:includePrereleases={settings.release_include_prereleases}
bind:checkIntervalHours={settings.release_check_interval_hours}
/>
<Button onclick={save} disabled={saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
bind:logLevels={settings.log_levels}
/>
</div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
<SaveBar
{dirty}
{saving}
changedCount={dirtyKeys.length}
onSave={save}
onDiscard={discard}
/>
{/if}
<ConfirmModal
open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => (confirmClearCache = false)}
/>
<style>
.settings-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.telegram-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.telegram-deck { grid-template-columns: 1fr 1fr; }
}
</style>
@@ -0,0 +1,406 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import Hint from '$lib/components/Hint.svelte';
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
oldest: string | null;
newest: string | null;
}
interface CacheStats {
url: CacheBucketStats;
asset: CacheBucketStats;
}
interface Props {
stats: CacheStats | null;
clearing: boolean;
maxEntries: number;
onRefresh: () => void;
onClear: () => void;
}
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function parseDate(iso: string | null): Date | null {
if (!iso) return null;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? null : d;
}
function relativeTime(iso: string | null): string {
const date = parseDate(iso);
if (!date) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function ageTone(iso: string | null): Tone {
const date = parseDate(iso);
if (!date) return 'mint';
const hours = (Date.now() - date.getTime()) / 3_600_000;
if (hours < 48) return 'mint';
if (hours < 24 * 7) return 'sky';
if (hours < 24 * 30) return 'citrus';
return 'coral';
}
interface BucketRow {
key: 'url' | 'asset';
labelKey: string;
icon: string;
data: CacheBucketStats | null;
}
const buckets = $derived<BucketRow[]>([
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
]);
const totalCount = $derived(
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
);
const totalBytes = $derived(
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
);
const fillPct = $derived.by(() => {
const max = Math.max(1, maxEntries);
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
void each; // explicit ack we considered both
return Math.min(100, Math.round((top / max) * 100));
});
</script>
<section class="ledger glass">
<header class="ledger-head">
<div class="ledger-summary">
<div class="ledger-eyebrow">
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
<span>{t('settings.cacheStats')}</span>
</div>
<div class="ledger-numbers">
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
{#if totalBytes > 0}
<span class="ledger-sep">·</span>
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
<Hint text={t('settings.cacheStatsHint')} />
{/if}
</div>
</div>
<div class="ledger-actions">
<button
type="button"
class="icon-btn"
onclick={onRefresh}
aria-label={t('common.refresh', 'Refresh')}
title={t('common.refresh', 'Refresh')}
>
<MdiIcon name="mdiRefresh" size={16} />
</button>
</div>
</header>
<!-- Capacity meter (peak bucket vs configured cap) -->
{#if maxEntries > 0}
<div class="meter" aria-label={t('settings.cacheCapacity')}>
<div class="meter-track">
<div class="meter-fill" style="width: {fillPct}%"></div>
</div>
<span class="meter-text font-mono">
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
</span>
</div>
{/if}
<!-- Bucket rows -->
<ol class="ledger-list">
{#each buckets as bucket (bucket.key)}
{@const data = bucket.data}
{@const empty = !data || data.count === 0}
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
<li class="row" data-tone={tone} class:row-empty={empty}>
<span class="row-edge" aria-hidden="true"></span>
<span class="row-icon" aria-hidden="true">
<MdiIcon name={bucket.icon} size={16} />
</span>
<div class="row-text">
<span class="row-name">{t(bucket.labelKey)}</span>
{#if empty}
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
{:else if data}
<span class="row-meta">
<span>
<span class="font-mono">{data.count.toLocaleString()}</span>
{t('settings.cacheStatsEntries')}
</span>
{#if data.total_size_bytes > 0}
<span class="row-sep">·</span>
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
{/if}
{#if data.oldest}
<span class="row-sep">·</span>
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
{/if}
</span>
{/if}
</div>
<span class="row-dot" aria-hidden="true"></span>
</li>
{/each}
</ol>
<footer class="ledger-foot">
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
{#if clearing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDeleteSweep" size={14} />
{/if}
{clearing ? t('common.loading') : t('settings.clearCache')}
</Button>
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
</footer>
</section>
<style>
.ledger {
padding: 1.4rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 100%;
}
.ledger-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ledger-summary { min-width: 0; }
.ledger-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.4rem;
}
.ledger-numbers {
display: flex;
align-items: baseline;
gap: 0.45rem;
line-height: 1;
flex-wrap: wrap;
}
.ledger-count {
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.ledger-count-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
.ledger-bytes {
font-size: 0.78rem;
color: var(--color-muted-foreground);
}
.ledger-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 30px; height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
/* --- Capacity meter --- */
.meter {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.65rem;
}
.meter-track {
flex: 1;
height: 6px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
overflow: hidden;
position: relative;
}
.meter-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
border-radius: inherit;
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
}
.meter-text {
font-size: 0.62rem;
color: var(--color-muted-foreground);
letter-spacing: 0.04em;
white-space: nowrap;
}
/* --- Bucket rows --- */
.ledger-list {
position: relative;
z-index: 1;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.85rem;
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
transition: transform 0.18s, border-color 0.18s, background 0.18s;
overflow: hidden;
}
.row:hover {
transform: translateY(-1px);
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.row.row-empty { opacity: 0.78; }
.row-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
.row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px; height: 30px;
border-radius: 9px;
background: var(--color-glass);
color: var(--color-foreground);
}
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
.row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.row-name {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
}
.row-meta {
font-size: 0.7rem;
color: var(--color-muted-foreground);
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.row-sep { opacity: 0.45; }
.row-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
/* --- Footer --- */
.ledger-foot {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
padding-top: 0.4rem;
margin-top: auto;
}
.foot-hint {
font-size: 0.7rem;
color: var(--color-muted-foreground);
flex: 1;
min-width: 12rem;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
.row, .meter-fill { transition: none !important; }
.row:hover { transform: none !important; }
}
</style>
@@ -0,0 +1,277 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess } from '$lib/stores/snackbar.svelte';
interface Props {
externalUrl: string;
timezone: string;
supportedLocales: string;
}
let {
externalUrl = $bindable(),
timezone = $bindable(),
supportedLocales = $bindable(),
}: Props = $props();
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | null = null;
function copyUrl(): void {
if (!externalUrl) return;
try {
navigator.clipboard.writeText(externalUrl);
copied = true;
snackSuccess(t('settings.urlCopied'));
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copied = false; }, 1600);
} catch { /* ignore */ }
}
function isReachable(url: string): boolean {
if (!url) return false;
try { new URL(url); return true; } catch { return false; }
}
const urlValid = $derived(isReachable(externalUrl));
</script>
<section class="identity glass">
<header class="identity-head">
<div class="identity-eyebrow">
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
<span>{t('settings.identity')}</span>
</div>
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
</header>
<div class="identity-body">
<!-- External URL row -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<label for="settings-external-url" class="row-name">
{t('settings.externalUrl')}
<Hint text={t('settings.externalUrlHint')} />
</label>
</div>
<div class="row-control">
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
<span class="url-leading" aria-hidden="true">
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
</span>
<input
id="settings-external-url"
bind:value={externalUrl}
placeholder="https://notify.example.com"
class="url-input"
type="url"
autocomplete="off"
spellcheck="false"
/>
{#if externalUrl}
<button
type="button"
class="url-action"
onclick={copyUrl}
aria-label={t('settings.copy')}
title={t('settings.copy')}
>
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
</button>
{#if urlValid}
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
class="url-action"
aria-label={t('settings.openExternal')}
title={t('settings.openExternal')}
>
<MdiIcon name="mdiOpenInNew" size={13} />
</a>
{/if}
{/if}
</div>
</div>
</div>
<!-- Timezone row -->
<div class="row">
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.timezone')}
<Hint text={t('settings.timezoneHint')} />
</span>
</div>
<div class="row-control">
<TimezoneSelector bind:value={timezone} />
</div>
</div>
<!-- Locales row -->
<div class="row">
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.supportedLocales')}
<Hint text={t('settings.supportedLocalesHint')} />
</span>
</div>
<div class="row-control">
<LocaleSelector bind:value={supportedLocales} />
</div>
</div>
</div>
</section>
<style>
.identity {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.identity-head {
position: relative;
z-index: 1;
}
.identity-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.identity-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 42ch;
}
.identity-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 1.4rem;
padding: 1rem 0;
border-top: 1px solid var(--color-border);
}
.row:first-child { border-top: 0; padding-top: 0.4rem; }
.row:last-child { padding-bottom: 0.1rem; }
.row-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.15rem;
}
.row-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.row-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
display: inline-flex;
align-items: center;
}
.row-control {
min-width: 0;
}
/* --- URL field with leading icon and trailing actions --- */
.url-field {
display: flex;
align-items: center;
gap: 0.25rem;
max-width: 34rem;
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
background: var(--color-input-bg);
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
.url-field:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.url-field-valid {
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
}
.url-leading {
color: var(--color-muted-foreground);
display: inline-flex;
flex-shrink: 0;
}
.url-field-valid .url-leading { color: var(--color-mint); }
.url-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
padding: 0.5rem 0.4rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-foreground);
min-width: 0;
}
.url-input::placeholder { color: var(--color-muted-foreground); }
.url-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.url-action:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.url-field, .url-action { transition: none !important; }
}
</style>
@@ -0,0 +1,448 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
interface Override {
module: string;
level: Level;
}
interface Props {
logLevel: string;
logFormat: string;
logLevels: string;
}
let {
logLevel = $bindable(),
logFormat = $bindable(),
logLevels = $bindable(),
}: Props = $props();
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
const LEVEL_TONE: Record<Level, string> = {
DEBUG: 'sky',
INFO: 'mint',
WARNING: 'citrus',
ERROR: 'coral',
};
let rawMode = $state(false);
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
function parse(csv: string): Override[] {
if (!csv) return [];
const out: Override[] = [];
const seen = new Set<string>();
for (const raw of csv.split(',')) {
const piece = raw.trim();
if (!piece) continue;
const eq = piece.indexOf('=');
if (eq < 0) continue;
const module = piece.slice(0, eq).trim();
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
if (!module || seen.has(module)) continue;
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
seen.add(module);
out.push({ module, level });
}
return out;
}
function serialize(rows: Override[]): string {
return rows
.filter(r => r.module.trim().length > 0)
.map(r => `${r.module.trim()}=${r.level}`)
.join(',');
}
let rows = $state<Override[]>(parse(logLevels));
let lastEmitted = $state(logLevels);
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
$effect(() => {
if (logLevels !== lastEmitted) {
rows = parse(logLevels);
lastEmitted = logLevels;
}
});
function commit(next: Override[]): void {
rows = next;
const serialized = serialize(next);
lastEmitted = serialized;
logLevels = serialized;
}
function addRow(): void {
commit([...rows, { module: '', level: 'INFO' }]);
}
function removeRow(i: number): void {
commit(rows.filter((_, idx) => idx !== i));
}
function updateModule(i: number, value: string): void {
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
commit(next);
}
function updateLevel(i: number, level: Level): void {
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
commit(next);
}
const previewLine = $derived.by(() => {
const root = (logLevel || 'INFO').toUpperCase();
if (rows.length === 0) return `root=${root}`;
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
});
</script>
<section class="logging glass">
<header class="log-head">
<div class="log-eyebrow">
<MdiIcon name="mdiTextBoxOutline" size={12} />
<span>{t('settings.logging')}</span>
</div>
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
</header>
<!-- Level + format -->
<div class="log-row">
<div class="log-cell">
<span class="log-label">
{t('settings.logLevel')}
<Hint text={t('settings.logLevelHint')} />
</span>
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
</div>
<div class="log-cell">
<span class="log-label">
{t('settings.logFormat')}
<Hint text={t('settings.logFormatHint')} />
</span>
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
</div>
</div>
<!-- Per-module overrides -->
<div class="overrides">
<div class="overrides-head">
<span class="log-label">
{t('settings.logLevels')}
<Hint text={t('settings.logLevelsHint')} />
</span>
<button
type="button"
class="mode-toggle"
onclick={() => (rawMode = !rawMode)}
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
>
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
</button>
</div>
{#if rawMode}
<input
bind:value={logLevels}
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
class="raw-input"
/>
{:else}
<div class="chip-stack">
{#each rows as row, i (i)}
{@const tone = LEVEL_TONE[row.level]}
<div class="chip" data-tone={tone}>
<span class="chip-edge" aria-hidden="true"></span>
<input
value={row.module}
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
placeholder={t('settings.logModulePlaceholder')}
class="chip-input"
autocomplete="off"
spellcheck="false"
/>
<span class="chip-sep" aria-hidden="true">=</span>
<select
value={row.level}
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
class="chip-level"
aria-label={t('settings.logLevel')}
>
{#each LEVELS as lvl}
<option value={lvl}>{lvl}</option>
{/each}
</select>
<button
type="button"
class="chip-remove"
onclick={() => removeRow(i)}
aria-label={t('settings.removeOverride')}
title={t('settings.removeOverride')}
>
<MdiIcon name="mdiClose" size={13} />
</button>
</div>
{/each}
<button type="button" class="chip-add" onclick={addRow}>
<MdiIcon name="mdiPlus" size={13} />
<span>{t('settings.addOverride')}</span>
</button>
</div>
{/if}
<!-- Live preview -->
<div class="preview" role="status">
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
<code class="preview-text">{previewLine}</code>
</div>
</div>
</section>
<style>
.logging {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.log-head {
position: relative;
z-index: 1;
}
.log-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.log-title {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 38ch;
}
.log-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
@media (min-width: 720px) {
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
}
.log-cell {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.log-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-foreground);
display: inline-flex;
align-items: center;
}
/* --- Overrides editor --- */
.overrides {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.overrides-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.mode-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.12em;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.mode-toggle:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
.raw-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.6rem 0.85rem;
}
.chip-stack {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chip {
position: relative;
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
overflow: hidden;
transition: border-color 0.18s, background 0.18s;
}
.chip:hover {
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.chip-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
.chip-input {
width: 100%;
background: transparent;
border: 0;
outline: 0;
padding: 0.35rem 0;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--color-foreground);
min-width: 0;
}
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
.chip-sep {
font-family: var(--font-mono);
color: var(--color-muted-foreground);
opacity: 0.5;
padding: 0 0.15rem;
}
.chip-level {
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 500;
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-glass);
color: var(--color-foreground);
min-width: 7.2rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
.chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px; height: 26px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chip-remove:hover {
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
color: var(--color-error-fg);
}
.chip-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
align-self: flex-start;
padding: 0.35rem 0.85rem;
border-radius: 999px;
border: 1px dashed var(--color-rule-strong);
background: transparent;
color: var(--color-muted-foreground);
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip-add:hover {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-primary);
border-style: solid;
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
}
/* --- Live preview --- */
.preview {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.65rem 0.85rem;
border-radius: 12px;
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
border: 1px solid var(--color-border);
overflow: hidden;
}
.preview-eyebrow {
font-family: var(--font-mono);
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.preview-text {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-foreground);
word-break: break-all;
line-height: 1.45;
}
</style>
@@ -0,0 +1,698 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import { api } from '$lib/api';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
interface Props {
// All five fields are persisted as strings via the /settings PUT —
// the parent owns the boundary type. Bool flags use "0" / "1".
providerKind: string;
providerUrl: string;
providerRepo: string;
includePrereleases: string;
checkIntervalHours: string;
}
let {
providerKind = $bindable(),
providerUrl = $bindable(),
providerRepo = $bindable(),
includePrereleases = $bindable(),
checkIntervalHours = $bindable(),
}: Props = $props();
let checking = $state(false);
let testing = $state(false);
let testResult = $state<ReleaseTestResult | null>(null);
const status = $derived(releaseStatusCache.value);
const prereleaseChecked = $derived(includePrereleases === '1');
const isDisabled = $derived(providerKind === 'disabled');
// Stale Test-result on input change is misleading — wipe whenever any of
// the probed parameters change so the strip reflects "current" state.
$effect(() => {
// Touch each parameter to register dependency.
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
testResult = null;
});
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
const stateTone: Tone = $derived.by(() => {
if (!status) return 'sky';
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
if (status.update_available) return 'citrus';
if (status.provider === 'disabled') return 'sky';
return 'mint';
});
const stateLabel = $derived.by(() => {
if (!status) return t('settings.release.statusUnknown');
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
if (status.update_available) return t('settings.release.statusUpdate');
if (status.latest) return t('settings.release.statusUpToDate');
return t('settings.release.statusUnknown');
});
// Map backend error taxonomy → localized text. Falls back to the raw code
// only when the key is missing (so a new server code surfaces something).
function localizedError(code: string | null): string {
if (!code) return '';
const key = `settings.release.error.${code}`;
const localized = t(key);
// `t` falls back to the key itself when missing — detect by exact match.
return localized === key ? code : localized;
}
function relTime(iso: string | null): string {
if (!iso) return t('settings.release.never');
const then = Date.parse(iso);
if (!Number.isFinite(then)) return t('settings.release.never');
const diff = Date.now() - then;
const min = Math.round(diff / 60_000);
if (min < 1) return t('settings.release.justNow');
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
const h = Math.round(min / 60);
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
const d = Math.round(h / 24);
return t('settings.release.daysAgo').replace('{n}', String(d));
}
function setProvider(kind: ReleaseProviderKind): void {
providerKind = kind;
}
function onIntervalInput(e: Event): void {
// The native input emits string values; we keep the contract by
// re-coercing to string before assigning to the bindable prop.
const raw = (e.currentTarget as HTMLInputElement).value;
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
}
async function checkNow(): Promise<void> {
checking = true;
try {
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
releaseStatusCache.set(next);
snackSuccess(t('settings.release.checkDone'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
} finally {
checking = false;
}
}
async function testProvider(): Promise<void> {
testing = true;
testResult = null;
try {
testResult = await api<ReleaseTestResult>('/settings/release/test', {
method: 'POST',
body: JSON.stringify({
provider_kind: providerKind,
provider_url: providerUrl,
provider_repo: providerRepo,
include_prereleases: prereleaseChecked,
}),
});
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
else snackError(t('settings.release.testFailed'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
} finally {
testing = false;
}
}
</script>
<section class="rel glass" id="release">
<header class="rel-head">
<div class="rel-eyebrow">
<MdiIcon name="mdiUpdate" size={12} />
<span>{t('settings.release.eyebrow')}</span>
</div>
<h3 class="rel-title">{t('settings.release.headline')}</h3>
</header>
<div class="rel-body">
<!-- 01 Provider — native radios for free keyboard a11y. -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<span class="row-name">
{t('settings.release.provider')}
<Hint text={t('settings.release.providerHint')} />
</span>
</div>
<div class="row-control">
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
<input
type="radio"
name="release-provider"
value="gitea"
checked={providerKind === 'gitea'}
onchange={() => setProvider('gitea')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
</label>
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
<input
type="radio"
name="release-provider"
value="github"
disabled
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
</label>
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
<input
type="radio"
name="release-provider"
value="disabled"
checked={providerKind === 'disabled'}
onchange={() => setProvider('disabled')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
</label>
</div>
</div>
</div>
<!-- 02 Repository -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.release.repository')}
<Hint text={t('settings.release.repositoryHint')} />
</span>
</div>
<div class="row-control repo-grid">
<input
bind:value={providerUrl}
placeholder="https://git.example.com"
class="text-input"
type="url"
spellcheck="false"
disabled={isDisabled}
/>
<input
bind:value={providerRepo}
placeholder="owner/repo"
class="text-input mono"
spellcheck="false"
disabled={isDisabled}
/>
</div>
</div>
<!-- 03 Options — slider toggle for include-prereleases. -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.release.options')}
<Hint text={t('settings.release.prereleasesHint')} />
</span>
</div>
<div class="row-control">
<button
type="button"
class="toggle"
class:toggle-disabled={isDisabled}
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
aria-pressed={prereleaseChecked}
disabled={isDisabled}
>
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
</button>
</div>
</div>
<!-- 04 Check interval -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">04</span>
<span class="row-name">
{t('settings.release.interval')}
<Hint text={t('settings.release.intervalHint')} />
</span>
</div>
<div class="row-control interval">
<input
type="number"
min={1}
max={168}
value={checkIntervalHours}
oninput={onIntervalInput}
class="text-input num"
disabled={isDisabled}
/>
<span class="unit">{t('settings.release.hoursUnit')}</span>
<span class="footnote">{t('settings.release.intervalRange')}</span>
</div>
</div>
</div>
<!-- State strip -->
<footer class="strip" data-tone={stateTone}>
<div class="strip-left">
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
<div class="strip-text">
<div class="strip-state">{stateLabel}</div>
<div class="strip-meta">
<span class="versions">
<span class="v-current">v{status?.current ?? '—'}</span>
{#if status?.latest && status.latest !== status.current}
<span class="arrow" aria-hidden="true"></span>
<span
class="v-latest"
class:v-latest-update={status.update_available}
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
{/if}
</span>
<span class="sep" aria-hidden="true">·</span>
<span class="checked">
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
</span>
</div>
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
</div>
{/if}
{#if testResult && !testResult.ok}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
{localizedError(testResult.error)}
</div>
{/if}
{#if testResult && testResult.ok && testResult.info}
<div class="strip-test-ok">
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
<span class="mono">v{testResult.info.version}</span>
</div>
{/if}
</div>
</div>
<div class="strip-actions">
{#if status?.update_available && status.latest_url}
<a
class="strip-btn strip-btn-cta"
href={status.latest_url}
target="_blank"
rel="noopener noreferrer"
>
<MdiIcon name="mdiOpenInNew" size={13} />
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
</a>
{/if}
<button
type="button"
class="strip-btn"
onclick={testProvider}
disabled={testing || isDisabled || !providerRepo}
>
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
<span>{t('settings.release.testConnection')}</span>
</button>
<button
type="button"
class="strip-btn strip-btn-primary"
onclick={checkNow}
disabled={checking || isDisabled}
>
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
<span>{t('settings.release.checkNow')}</span>
</button>
</div>
</footer>
</section>
<style>
.rel {
padding: 1.5rem 1.6rem 0;
display: flex;
flex-direction: column;
gap: 1.2rem;
overflow: hidden;
}
.rel-head { position: relative; z-index: 1; }
.rel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.rel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 42ch;
}
.rel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 1.4rem;
padding: 1rem 0;
border-top: 1px solid var(--color-border);
}
.row:first-child { border-top: 0; padding-top: 0.4rem; }
.row-dim { opacity: 0.55; }
.row-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.15rem;
}
.row-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.row-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
display: inline-flex;
align-items: center;
}
.row-control { min-width: 0; }
/* Segmented provider control — uses real radios so arrow-key + tab
navigation just work via the browser. */
.seg {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
}
.seg-item {
display: inline-flex;
align-items: center;
border-radius: 0.45rem;
cursor: pointer;
position: relative;
}
.seg-radio {
position: absolute;
opacity: 0;
pointer-events: none;
inset: 0;
}
.seg-radio:focus-visible + .seg-content {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.seg-content {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 0.45rem;
font-size: 0.78rem;
color: var(--color-muted-foreground);
transition: background 0.18s, color 0.18s;
}
.seg-item:hover:not(.seg-soon) .seg-content {
color: var(--color-foreground);
background: var(--color-glass);
}
.seg-active .seg-content {
color: var(--color-foreground);
background: var(--color-input-bg);
box-shadow: 0 0 0 1px var(--color-primary);
}
.seg-soon { opacity: 0.45; cursor: not-allowed; }
/* Text fields */
.repo-grid {
display: grid;
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
gap: 0.6rem;
max-width: 100%;
}
.text-input {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
background: var(--color-input-bg);
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--color-foreground);
transition: border-color 0.18s, box-shadow 0.18s;
}
.text-input.mono { font-family: var(--font-mono); }
.text-input.num { max-width: 6rem; text-align: right; }
.text-input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* Interval */
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.unit {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footnote {
font-size: 0.68rem;
color: var(--color-muted-foreground);
font-style: italic;
}
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
.toggle {
display: inline-flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: var(--color-foreground);
cursor: pointer;
}
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label-text { font-size: 0.82rem; }
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
/* State strip */
.strip {
margin: 0 -1.6rem;
padding: 1rem 1.6rem;
border-top: 1px solid var(--color-border);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
transparent
);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
position: relative;
}
.strip[data-tone="citrus"]::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 10%,
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
transparent 90%
);
animation: aurora-shimmer 4s linear infinite;
}
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
margin-top: 0.45rem;
flex-shrink: 0;
}
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.strip-state {
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
}
.strip-meta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.74rem;
color: var(--color-muted-foreground);
}
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
.arrow { color: var(--color-muted-foreground); }
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
.sep { opacity: 0.5; }
.rel-time { color: var(--color-foreground); }
.strip-error {
font-size: 0.72rem;
color: var(--color-coral, #d27a7a);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-test-ok {
font-size: 0.72rem;
color: var(--color-mint, #6fcfa6);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
.strip-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.55rem;
background: var(--color-input-bg);
font-size: 0.76rem;
color: var(--color-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.18s, border-color 0.18s, transform 0.18s;
}
.strip-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
border-color: var(--color-primary);
}
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.strip-btn-primary {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
}
/* The CTA — high-visibility when an update is available. */
.strip-btn-cta {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
color: var(--color-foreground);
font-weight: 500;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
}
.strip-btn-cta:hover {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
}
.mono { font-family: var(--font-mono); }
@keyframes aurora-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.strip[data-tone="citrus"]::before { animation: none; }
.strip-btn { transition: none; }
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
.repo-grid { grid-template-columns: 1fr; }
.strip { flex-direction: column; align-items: stretch; }
.strip-actions { justify-content: stretch; }
.strip-btn { flex: 1; justify-content: center; }
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface Props {
dirty: boolean;
saving: boolean;
changedCount: number;
onSave: () => void;
onDiscard: () => void;
}
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
</script>
{#if dirty || saving}
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
<div class="save-bar-inner glass">
<span class="save-edge" aria-hidden="true"></span>
<span class="save-pulse" aria-hidden="true"></span>
<div class="save-text">
<span class="save-eyebrow">{t('settings.unsaved')}</span>
<span class="save-message">
{#if changedCount === 1}
{t('settings.changedOne')}
{:else}
{t('settings.changedMany').replace('{n}', String(changedCount))}
{/if}
</span>
</div>
<div class="save-actions">
<button
type="button"
class="discard"
onclick={onDiscard}
disabled={saving}
>
{t('settings.discard')}
</button>
<Button size="sm" onclick={onSave} disabled={saving}>
{#if saving}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiContentSave" size={14} />
{/if}
{saving ? t('common.loading') : t('settings.saveChanges')}
</Button>
</div>
</div>
</div>
{/if}
<style>
.save-bar {
position: sticky;
bottom: 1rem;
z-index: 40;
margin-top: 1.25rem;
display: flex;
justify-content: center;
pointer-events: none;
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
}
.save-bar-inner {
pointer-events: auto;
position: relative;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.7rem 1rem 0.7rem 1.25rem;
max-width: min(640px, calc(100% - 1rem));
width: 100%;
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
box-shadow:
var(--shadow-card),
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
overflow: hidden;
flex-wrap: wrap;
}
.save-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
}
.save-pulse {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-citrus);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
animation: save-pulse 1.6s ease-in-out infinite;
}
.save-text {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
padding-left: 1rem; /* clear room for the pulse dot */
}
.save-eyebrow {
font-family: var(--font-mono);
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-citrus);
}
.save-message {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 0.95rem;
color: var(--color-foreground);
letter-spacing: -0.01em;
line-height: 1.2;
}
.save-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.discard {
padding: 0 0.95rem;
height: 34px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-family: inherit;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.discard:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
.discard:disabled { opacity: 0.5; cursor: default; }
@keyframes save-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes save-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.save-bar, .save-pulse { animation: none !important; }
}
</style>
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
interface Settings {
external_url: string;
timezone: string;
supported_locales: string;
log_level: string;
log_format: string;
}
interface Props {
settings: Settings;
}
let { settings }: Props = $props();
// Live tick so the timezone pill shows the current local HH:MM.
let now = $state(new Date());
let tick: ReturnType<typeof setInterval> | null = null;
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
onDestroy(() => { if (tick) clearInterval(tick); });
function fmtClock(tz: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz || 'UTC',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now);
} catch { return '--:--'; }
}
function hostFromUrl(url: string): string {
if (!url) return '';
try { return new URL(url).host; }
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
}
function localeCount(csv: string): number {
if (!csv) return 0;
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
}
const SEVERITY_TONE: Record<string, Tone> = {
DEBUG: 'sky',
INFO: 'mint',
WARNING: 'citrus',
ERROR: 'coral',
};
const pills = $derived.by<HeaderPill[]>(() => {
const out: HeaderPill[] = [];
const host = hostFromUrl(settings.external_url);
out.push(host
? { label: host, tone: 'sky' }
: { label: t('settings.heroNoUrl') }
);
const tz = settings.timezone || 'UTC';
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
const locales = settings.supported_locales || '';
const count = localeCount(locales);
out.push({
label: count > 0
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
: t('settings.heroNoLocales'),
tone: 'orchid',
});
const lvl = (settings.log_level || 'INFO').toUpperCase();
out.push({
label: `${lvl} · ${settings.log_format || 'text'}`,
tone: SEVERITY_TONE[lvl] ?? 'mint',
});
const rs = releaseStatusCache.value;
if (rs) {
if (rs.provider === 'disabled') {
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
} else if (rs.error && rs.error !== 'provider_changed') {
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
} else if (rs.update_available && rs.latest) {
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
} else if (rs.latest) {
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
}
}
return out;
});
</script>
<PageHeader
title={t('settings.title')}
emphasis={t('settings.titleEmphasis')}
description={t('settings.description')}
crumb={t('crumbs.systemConfiguration')}
{pills}
/>
@@ -0,0 +1,344 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
interface Props {
webhookSecret: string;
cacheTtlHours: string;
cacheMaxEntries: string;
}
let {
webhookSecret = $bindable(),
cacheTtlHours = $bindable(),
cacheMaxEntries = $bindable(),
}: Props = $props();
let showSecret = $state(false);
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
const ttlHours = $derived(Number(cacheTtlHours || '0'));
const ttlIsOff = $derived(ttlHours <= 0);
function ttlHumanized(h: number): string {
if (h <= 0) return t('settings.ttlNoExpiry');
if (h < 24) return `${h}h`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d`;
const w = Math.round(d / 7);
if (w < 8) return `${w}w`;
const mo = Math.round(d / 30);
return `${mo}mo`;
}
</script>
<section class="tg glass">
<header class="tg-head">
<div class="tg-eyebrow">
<MdiIcon name="mdiSend" size={12} />
<span>{t('settings.telegram')}</span>
</div>
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
</header>
<div class="tg-grid">
<!-- Webhook secret column -->
<div class="col">
<div class="col-head">
<span class="col-num">A</span>
<span class="col-name">
{t('settings.webhookSecret')}
<Hint text={t('settings.webhookSecretHint')} />
</span>
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
<span class="dot"></span>
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
</span>
</div>
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input
bind:value={webhookSecret}
type={showSecret ? 'text' : 'password'}
autocomplete="off"
placeholder={t('providers.optional')}
class="secret-input"
/>
<button
type="button"
class="secret-toggle"
onclick={() => (showSecret = !showSecret)}
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
title={showSecret ? t('settings.hide') : t('settings.show')}
>
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
</button>
</form>
</div>
<!-- Cache config column -->
<div class="col">
<div class="col-head">
<span class="col-num">B</span>
<span class="col-name">{t('settings.cacheConfig')}</span>
</div>
<div class="cache-grid">
<label class="num-field">
<span class="num-label">
{t('settings.cacheTtlShort')}
<Hint text={t('settings.cacheTtlHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheTtlHours}
type="number"
min="0"
max="8760"
class="num-input"
/>
<span class="num-suffix">{t('settings.hoursShort')}</span>
</div>
<span class="num-meta" class:num-meta-off={ttlIsOff}>
{ttlHumanized(ttlHours)}
</span>
</label>
<label class="num-field">
<span class="num-label">
{t('settings.cacheMaxShort')}
<Hint text={t('settings.cacheMaxEntriesHint')} />
</span>
<div class="num-row">
<input
bind:value={cacheMaxEntries}
type="number"
min="100"
max="100000"
class="num-input"
/>
<span class="num-suffix">{t('settings.entriesShort')}</span>
</div>
<span class="num-meta">
{t('settings.cacheMaxFootnote')}
</span>
</label>
</div>
</div>
</div>
</section>
<style>
.tg {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
min-height: 100%;
}
.tg-head {
position: relative;
z-index: 1;
}
.tg-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.tg-title {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.tg-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 720px) {
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
}
.col {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.col-head {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.col-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
text-transform: uppercase;
}
.col-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
display: inline-flex;
align-items: center;
}
.col-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
}
.col-status .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-muted-foreground);
}
.col-status[data-state="set"] {
color: var(--color-mint);
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
}
.col-status[data-state="set"] .dot {
background: var(--color-mint);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
}
/* --- Secret field --- */
.secret-field {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.625rem;
background: var(--color-input-bg);
transition: border-color 0.18s, box-shadow 0.18s;
}
.secret-field:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.secret-input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
padding: 0.5rem 0.4rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-foreground);
min-width: 0;
letter-spacing: 0.05em;
}
.secret-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 8px;
background: transparent;
border: 0;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.secret-toggle:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
/* --- Cache config grid --- */
.cache-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
.num-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.7rem 0.85rem 0.65rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.num-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
.num-row {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.num-input {
width: 100%;
padding: 0.1rem 0;
border: 0;
background: transparent;
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--color-foreground);
letter-spacing: -0.015em;
line-height: 1.1;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.num-suffix {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.num-meta {
font-size: 0.7rem;
color: var(--color-mint);
font-family: var(--font-mono);
}
.num-meta-off {
color: var(--color-citrus);
}
@media (max-width: 480px) {
.cache-grid { grid-template-columns: 1fr; }
}
</style>
+261 -400
View File
@@ -2,8 +2,6 @@
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -11,32 +9,55 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
// --- Export state ---
let exportSecrets = $state('exclude');
let exporting = $state(false);
import BackupHero from './BackupHero.svelte';
import PendingStrip from './PendingStrip.svelte';
import ExportPanel from './ExportPanel.svelte';
import ImportPanel from './ImportPanel.svelte';
import ScheduleCassette from './ScheduleCassette.svelte';
import BackupLedger from './BackupLedger.svelte';
const categories = [
{ key: 'providers', label: 'backup.catProviders' },
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
{ key: 'email_bots', label: 'backup.catEmailBots' },
{ key: 'targets', label: 'backup.catTargets' },
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
{ key: 'actions', label: 'backup.catActions' },
{ key: 'app_settings', label: 'backup.catAppSettings' },
type SecretsMode = 'exclude' | 'masked' | 'include';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
const allCategories = [
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
'tracking_configs', 'template_configs',
'command_configs', 'command_template_configs',
'notification_trackers', 'command_trackers',
'actions', 'app_settings',
];
// --- Export state ---
let exportSecrets = $state<SecretsMode>('exclude');
let exporting = $state(false);
let selectedCategories = $state<Record<string, boolean>>(
Object.fromEntries(categories.map(c => [c.key, true]))
Object.fromEntries(allCategories.map(k => [k, true]))
);
// --- Import state ---
let importFile: File | null = $state(null);
let importConflict = $state('skip');
let importConflict = $state<ConflictMode>('skip');
let importing = $state(false);
let validating = $state(false);
let validationResult: any = $state(null);
@@ -47,7 +68,7 @@
// --- Scheduled backup state ---
let loaded = $state(false);
let error = $state('');
let scheduledSettings = $state({
let scheduledSettings = $state<ScheduledSettings>({
backup_scheduled_enabled: 'false',
backup_scheduled_interval_hours: '24',
backup_secrets_mode: 'exclude',
@@ -56,22 +77,22 @@
let savingSchedule = $state(false);
// --- Backup files ---
let backupFiles = $state<any[]>([]);
let backupFiles = $state<BackupFile[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let pending = $state<PendingState | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
api<ScheduledSettings>('/backup/scheduled'),
api<BackupFile[]>('/backup/files'),
api<PendingState>('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
@@ -84,7 +105,7 @@
}
});
async function cancelPending() {
async function cancelPending(): Promise<void> {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
@@ -92,14 +113,13 @@
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
async function applyAndRestart(): Promise<void> {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
@@ -117,7 +137,7 @@
}
}
async function createManualBackup() {
async function createManualBackup(): Promise<void> {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
@@ -132,7 +152,7 @@
}
// --- Export ---
async function doExport() {
async function doExport(): Promise<void> {
if (exportSecrets === 'include') {
confirmExportOpen = true;
return;
@@ -140,7 +160,7 @@
await performExport();
}
async function performExport() {
async function performExport(): Promise<void> {
confirmExportOpen = false;
exporting = true;
try {
@@ -165,8 +185,14 @@
}
}
// --- Validate ---
async function validateFile() {
// --- Validate / Import ---
function handleFileSelect(file: File | null): void {
importFile = file;
validationResult = null;
importResult = null;
}
async function validateFile(): Promise<void> {
if (!importFile) return;
validating = true;
validationResult = null;
@@ -183,12 +209,11 @@
}
}
// --- Import ---
async function doImport() {
function doImport(): void {
confirmImportOpen = true;
}
async function performImport() {
async function performImport(): Promise<void> {
confirmImportOpen = false;
if (!importFile) return;
importing = true;
@@ -213,10 +238,10 @@
}
// --- Scheduled settings ---
async function saveSchedule() {
async function saveSchedule(): Promise<void> {
savingSchedule = true;
try {
scheduledSettings = await api('/backup/scheduled', {
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
method: 'PUT',
body: JSON.stringify(scheduledSettings),
});
@@ -229,10 +254,10 @@
}
// --- File management ---
async function refreshFiles() {
async function refreshFiles(): Promise<void> {
loadingFiles = true;
try {
backupFiles = await api('/backup/files');
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} finally {
@@ -240,7 +265,7 @@
}
}
async function downloadFile(filename: string) {
async function downloadFile(filename: string): Promise<void> {
try {
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -255,7 +280,7 @@
}
}
async function deleteFile(filename: string) {
async function deleteFile(filename: string): Promise<void> {
try {
await api(`/backup/files/${filename}`, { method: 'DELETE' });
snackSuccess(t('backup.fileDeleted'));
@@ -265,355 +290,61 @@
snackError(err.message);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.length) {
importFile = input.files[0];
validationResult = null;
importResult = null;
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
function toggleAll() {
const newVal = !allSelected;
for (const key of Object.keys(selectedCategories)) {
selectedCategories[key] = newVal;
}
}
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb="System · Maintenance"
/>
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
{#if !loaded}
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
<div class="backup-page stagger-children">
<div class="action-deck">
<ExportPanel
{selectedCategories}
{exportSecrets}
{exporting}
onCategoriesChange={(next) => selectedCategories = next}
onSecretsChange={(next) => exportSecrets = next}
onExport={doExport}
/>
<ImportPanel
{importFile}
{importConflict}
{validating}
{validationResult}
{importing}
{importResult}
onFileSelect={handleFileSelect}
onConflictChange={(mode) => importConflict = mode}
onValidate={validateFile}
onImport={doImport}
/>
</div>
{/if}
<div class="space-y-6">
<ScheduleCassette
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
bind:secretsMode={scheduledSettings.backup_secrets_mode}
bind:retentionCount={scheduledSettings.backup_retention_count}
saving={savingSchedule}
onToggle={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
onSave={saveSchedule}
/>
<!-- Export Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseExport" size={18} />
{t('backup.export')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
<!-- Categories -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium">{t('backup.categories')}</span>
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
{#each categories as cat}
<label class="flex items-center gap-1.5 text-xs">
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
{t(cat.label)}
</label>
{/each}
</div>
</div>
<!-- Secrets mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="exclude" />
{t('backup.secretsExclude')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="masked" />
{t('backup.secretsMasked')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={exportSecrets} value="include" />
{t('backup.secretsInclude')}
</label>
</div>
{#if exportSecrets === 'include'}
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiAlert" size={14} />
{t('backup.secretsWarningExport')}
</div>
{/if}
</div>
<Button onclick={doExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</Card>
<!-- Import Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiDatabaseImport" size={18} />
{t('backup.import')}
</h3>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
<!-- File picker -->
<div class="mb-4">
<input type="file" accept=".json" onchange={handleFileSelect}
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
</div>
{#if importFile}
<!-- Validate -->
<div class="mb-4 flex items-center gap-2">
<Button variant="secondary" onclick={validateFile} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckCircleOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
</div>
{#if validationResult}
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="flex items-center gap-2 mb-2 font-medium">
{#if validationResult.valid}
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
{:else}
<MdiIcon name="mdiCloseCircle" size={14} />
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
{/if}
</div>
{#if Object.keys(validationResult.entity_counts || {}).length}
<div class="mb-2">
<span class="font-medium">{t('backup.entities')}:</span>
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
<span class="inline-block mr-2">{cat}: {count}</span>
{/each}
</div>
{/if}
{#each validationResult.warnings || [] as w}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
<MdiIcon name="mdiAlert" size={12} />
<span>{w}</span>
</div>
{/each}
{#each validationResult.errors || [] as e}
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={12} />
<span>{e}</span>
</div>
{/each}
</div>
{/if}
<!-- Conflict mode -->
<div class="mb-4">
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
<div class="flex flex-col gap-1.5">
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="skip" />
{t('backup.conflictSkip')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="rename" />
{t('backup.conflictRename')}
</label>
<label class="flex items-center gap-1.5 text-xs">
<input type="radio" bind:group={importConflict} value="overwrite" />
{t('backup.conflictOverwrite')}
</label>
</div>
</div>
<Button onclick={doImport}
disabled={importing || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
<div class="font-medium mb-1">{t('backup.importResults')}</div>
<div class="space-y-0.5">
<div>{t('backup.resultCreated')}: {importResult.created}</div>
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
{#if importResult.errors?.length}
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
{#each importResult.errors as e}
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
{/each}
{/if}
{#if importResult.warnings?.length}
{#each importResult.warnings as w}
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
{/each}
{/if}
</div>
</div>
{/if}
{/if}
</Card>
<!-- Scheduled Backups Section -->
<Card>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<MdiIcon name="mdiClockOutline" size={18} />
{t('backup.scheduled')}
</h3>
<div class="space-y-3">
<label class="flex items-center gap-2 text-xs">
<input type="checkbox"
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
onchange={() => scheduledSettings.backup_scheduled_enabled =
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
<span class="font-medium">{t('backup.enableScheduled')}</span>
</label>
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="6">6 {t('backup.hours')}</option>
<option value="12">12 {t('backup.hours')}</option>
<option value="24">24 {t('backup.hours')}</option>
<option value="48">48 {t('backup.hours')}</option>
<option value="72">72 {t('backup.hours')}</option>
<option value="168">168 {t('backup.hours')} (7d)</option>
</select>
</div>
<div>
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="exclude">{t('backup.secretsExclude')}</option>
<option value="masked">{t('backup.secretsMasked')}</option>
<option value="include">{t('backup.secretsInclude')}</option>
</select>
</div>
<div>
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="3">3</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
{/if}
</div>
<div class="mt-4">
<Button onclick={saveSchedule} disabled={savingSchedule}>
{savingSchedule ? t('common.loading') : t('common.save')}
</Button>
</div>
</Card>
<!-- Saved Backup Files -->
<Card>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold flex items-center gap-2">
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
</div>
</div>
{#if backupFiles.length === 0}
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
{:else}
<div class="space-y-2">
{#each backupFiles as file}
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
style="border-color: var(--color-border);">
<div class="flex items-center gap-2">
<MdiIcon name="mdiFileDocument" size={14} />
<span class="font-mono">{file.filename}</span>
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
</div>
<div class="flex items-center gap-1">
<button onclick={() => downloadFile(file.filename)}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button onclick={() => confirmDeleteFile = file.filename}
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
style="color: var(--color-error-fg);">
<MdiIcon name="mdiDelete" size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</Card>
<BackupLedger
files={backupFiles}
loading={loadingFiles}
creating={creatingBackup}
onCreate={createManualBackup}
onRefresh={refreshFiles}
onDownload={downloadFile}
onDelete={(filename) => confirmDeleteFile = filename}
/>
</div>
{/if}
@@ -652,27 +383,25 @@
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
class="post-restore-card"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<div class="post-restore-head">
<div class="post-restore-icon">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
<div class="post-restore-text">
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
<p>{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
<div class="post-restore-actions">
<button class="post-restore-later" type="button"
onclick={() => postRestoreModalOpen = false}>
{t('backup.applyLater')}
</button>
{#if pending.supervised}
@@ -687,30 +416,162 @@
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<div class="restart-overlay" role="alert" aria-live="assertive">
<div class="restart-card">
<div class="restart-spinner">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
<p class="restart-title">{t('backup.restartingTitle')}</p>
<p class="restart-sub">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.backup-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.action-deck {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: stretch;
}
@media (min-width: 960px) {
.action-deck { grid-template-columns: 1fr 1fr; }
}
/* Post-restore modal */
.post-restore-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.post-restore-card {
background: var(--color-glass-elev);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-rule-strong);
border-radius: 22px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
}
.post-restore-head {
display: flex;
align-items: flex-start;
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.post-restore-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-warning-bg);
color: var(--color-warning-fg);
flex-shrink: 0;
}
.post-restore-text { min-width: 0; }
.post-restore-text h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 500;
font-size: 1.15rem;
margin: 0 0 0.25rem;
letter-spacing: -0.015em;
}
.post-restore-text p {
font-size: 0.82rem;
color: var(--color-muted-foreground);
margin: 0;
line-height: 1.45;
word-wrap: break-word;
}
.post-restore-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.post-restore-later {
padding: 0 0.95rem;
height: 34px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.post-restore-later:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
/* Restarting overlay */
.restart-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.restart-card {
text-align: center;
padding: 1.6rem 2rem;
color: var(--color-foreground);
}
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-bottom: 0.85rem;
color: var(--color-primary);
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
.restart-title {
font-family: var(--font-display);
font-style: italic;
font-size: 1.2rem;
font-weight: 500;
margin: 0;
letter-spacing: -0.015em;
}
.restart-sub {
font-size: 0.8rem;
color: var(--color-muted-foreground);
margin: 0.4rem 0 0;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.restart-spinner { animation: none !important; }
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
interface ScheduledSettings {
backup_scheduled_enabled: string;
backup_scheduled_interval_hours: string;
backup_secrets_mode: string;
backup_retention_count: string;
}
interface Props {
files: BackupFile[];
scheduled: ScheduledSettings;
pending: { pending: boolean } | null;
}
let { files, scheduled, pending }: Props = $props();
function relativeTime(iso: string | null | undefined): string {
if (!iso) return '';
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function latestCreatedAt(list: BackupFile[]): string | null {
const stamps = list
.map(f => f.created_at)
.filter((s): s is string => !!s)
.sort();
return stamps.length ? stamps[stamps.length - 1] : null;
}
function ageHours(iso: string | null): number {
if (!iso) return Infinity;
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
if (isNaN(date.getTime())) return Infinity;
return (Date.now() - date.getTime()) / 3_600_000;
}
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
const out: Array<{ label: string; tone?: Tone }> = [];
if (pending?.pending) {
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
}
if (scheduled.backup_scheduled_enabled === 'true') {
out.push({
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
tone: 'mint',
});
} else {
out.push({ label: t('backup.scheduleOff') });
}
const latest = latestCreatedAt(files);
if (latest) {
const hours = ageHours(latest);
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
} else {
out.push({ label: t('backup.never'), tone: 'citrus' });
}
return out;
});
</script>
<PageHeader
title={t('backup.title')}
emphasis={t('backup.titleEmphasis')}
description={t('backup.description')}
crumb={t('crumbs.systemMaintenance')}
count={files.length}
countLabel={t('backup.countLabel')}
{pills}
/>
@@ -0,0 +1,357 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface BackupFile {
filename: string;
size: number;
created_at?: string | null;
}
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
interface Props {
files: BackupFile[];
loading: boolean;
creating: boolean;
onCreate: () => void;
onRefresh: () => void;
onDownload: (filename: string) => void;
onDelete: (filename: string) => void;
}
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseDate(iso: string | null | undefined): Date | null {
if (!iso) return null;
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? null : d;
}
function relativeTime(iso: string | null | undefined): string {
const date = parseDate(iso);
if (!date) return '';
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (diffSec < 60) return t('dashboard.justNow');
const min = Math.floor(diffSec / 60);
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
const day = Math.floor(hr / 24);
return t('dashboard.daysAgo').replace('{n}', String(day));
}
function absoluteTime(iso: string | null | undefined): string {
const date = parseDate(iso);
return date ? date.toLocaleString() : '—';
}
function ageTone(iso: string | null | undefined): Tone {
const date = parseDate(iso);
if (!date) return 'coral';
const hours = (Date.now() - date.getTime()) / 3_600_000;
if (hours < 48) return 'mint';
if (hours < 24 * 7) return 'sky';
if (hours < 24 * 30) return 'citrus';
return 'coral';
}
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
</script>
<section class="ledger glass">
<header class="ledger-head">
<div>
<div class="ledger-eyebrow">
<MdiIcon name="mdiArchiveOutline" size={12} />
<span>{t('backup.savedFiles')}</span>
</div>
{#if files.length > 0}
<div class="ledger-summary">
<span class="ledger-count font-mono">{files.length}</span>
<span class="ledger-count-label">{t('backup.countLabel')}</span>
<span class="ledger-sep">·</span>
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
</div>
{/if}
</div>
<div class="ledger-actions">
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
{#if creating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiPlus" size={14} />
{/if}
{creating ? t('common.loading') : t('backup.createManual')}
</Button>
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
</button>
</div>
</header>
{#if files.length === 0}
<div class="ledger-empty">
<MdiIcon name="mdiCloudOffOutline" size={28} />
<p>{t('backup.noFiles')}</p>
</div>
{:else}
<ol class="ledger-list">
{#each files as file (file.filename)}
{@const tone = ageTone(file.created_at)}
<li class="row" data-tone={tone}>
<span class="row-edge" aria-hidden="true"></span>
<span class="row-dot" aria-hidden="true"></span>
<div class="row-time">
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
<span class="row-abs" title={absoluteTime(file.created_at)}>
{absoluteTime(file.created_at)}
</span>
</div>
<div class="row-name">
<span class="row-filename" title={file.filename}>{file.filename}</span>
</div>
<span class="row-size font-mono">{formatBytes(file.size)}</span>
<div class="row-actions">
<button class="icon-btn" type="button"
onclick={() => onDownload(file.filename)}
aria-label={t('backup.download')}
title={t('backup.download')}>
<MdiIcon name="mdiDownload" size={14} />
</button>
<button class="icon-btn icon-btn-danger" type="button"
onclick={() => onDelete(file.filename)}
aria-label={t('common.delete')}
title={t('common.delete')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
</button>
</div>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.ledger {
padding: 1.4rem 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.95rem;
}
.ledger-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ledger-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.3rem;
}
.ledger-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
line-height: 1;
}
.ledger-count {
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.025em;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.ledger-count-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
.ledger-total {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.ledger-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.icon-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-border);
}
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn-danger:hover:not(:disabled) {
color: var(--color-error-fg);
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
}
.spinning {
display: inline-flex;
animation: ledger-spin 1.1s linear infinite;
}
@keyframes ledger-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ledger-empty {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.6rem 1rem;
color: var(--color-muted-foreground);
text-align: center;
}
.ledger-empty p { margin: 0; font-size: 0.8rem; }
.ledger-list {
position: relative;
z-index: 1;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.row {
position: relative;
display: grid;
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
transition: transform 0.18s, border-color 0.18s, background 0.18s;
overflow: hidden;
}
.row:hover {
transform: translateY(-1px);
border-color: var(--color-rule-strong);
background: var(--color-glass-elev);
}
.row-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
opacity: 0.85;
}
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
.row-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
.row-time {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 6.5rem;
}
.row-rel {
font-size: 0.78rem;
color: var(--color-foreground);
font-weight: 500;
letter-spacing: -0.005em;
}
.row-abs {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
}
.row-name {
min-width: 0;
}
.row-filename {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row:hover .row-filename { color: var(--color-foreground); }
.row-size {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-align: right;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.15rem;
opacity: 0;
transition: opacity 0.18s;
}
.row:hover .row-actions,
.row:focus-within .row-actions { opacity: 1; }
@media (max-width: 640px) {
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
.row-time { grid-column: 2; min-width: 0; }
.row-name { grid-column: 1 / -1; }
.row-size { grid-column: 3; grid-row: 1; }
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.row { transition: none !important; }
.row:hover { transform: none !important; }
.spinning { animation: none !important; }
}
</style>
@@ -0,0 +1,392 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type SecretsMode = 'exclude' | 'masked' | 'include';
interface Props {
selectedCategories: Record<string, boolean>;
exportSecrets: SecretsMode;
exporting: boolean;
onCategoriesChange: (next: Record<string, boolean>) => void;
onSecretsChange: (next: SecretsMode) => void;
onExport: () => void;
}
let {
selectedCategories,
exportSecrets,
exporting,
onCategoriesChange,
onSecretsChange,
onExport,
}: Props = $props();
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
{
key: 'identity',
labelKey: 'backup.catGroupIdentity',
icon: 'mdiAccountNetwork',
cats: [
{ key: 'providers', labelKey: 'backup.catProviders' },
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
{ key: 'targets', labelKey: 'backup.catTargets' },
],
},
{
key: 'notif',
labelKey: 'backup.catGroupNotif',
icon: 'mdiBellOutline',
cats: [
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
],
},
{
key: 'cmd',
labelKey: 'backup.catGroupCmd',
icon: 'mdiConsoleLine',
cats: [
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
],
},
{
key: 'system',
labelKey: 'backup.catGroupSystem',
icon: 'mdiCog',
cats: [
{ key: 'actions', labelKey: 'backup.catActions' },
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
],
},
];
function toggleCat(key: string): void {
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
}
function groupState(groupKey: string): 'all' | 'none' | 'some' {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return 'none';
const flags = group.cats.map(c => !!selectedCategories[c.key]);
if (flags.every(v => v)) return 'all';
if (flags.every(v => !v)) return 'none';
return 'some';
}
function toggleGroup(groupKey: string): void {
const group = categoryGroups.find(g => g.key === groupKey);
if (!group) return;
const target = groupState(groupKey) !== 'all';
const next = { ...selectedCategories };
for (const c of group.cats) next[c.key] = target;
onCategoriesChange(next);
}
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
];
</script>
<section class="export-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseExport" size={14} />
<span>{t('backup.export')}</span>
</div>
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: categories -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepCategories')}</span>
<span class="step-count">{totalSelected}</span>
</div>
<div class="group-grid">
{#each categoryGroups as group}
{@const state = groupState(group.key)}
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
<span class="group-title">{t(group.labelKey)}</span>
<span class="group-state">
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
</span>
</button>
<div class="chip-row">
{#each group.cats as cat}
<button class="chip" type="button"
class:chip-on={selectedCategories[cat.key]}
onclick={() => toggleCat(cat.key)}>
{t(cat.labelKey)}
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Step 2: secrets -->
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepSecrets')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
{#each secretsModes as mode}
<button type="button"
role="radio"
aria-checked={exportSecrets === mode.value}
class="seg"
class:seg-on={exportSecrets === mode.value}
onclick={() => onSecretsChange(mode.value)}>
<MdiIcon name={mode.icon} size={14} />
<span>{t(mode.labelKey)}</span>
</button>
{/each}
</div>
{#if exportSecrets === 'include'}
<div class="warn-strip" role="status">
<span class="warn-edge" aria-hidden="true"></span>
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
<span>{t('backup.secretsWarningExport')}</span>
</div>
{/if}
</div>
<!-- Step 3: CTA -->
<div class="step step-cta">
<Button onclick={onExport} disabled={exporting || noneSelected}>
{#if exporting}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiDownload" size={14} />
{/if}
{exporting ? t('common.loading') : t('backup.exportBtn')}
</Button>
</div>
</div>
</section>
<style>
.export-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head {
position: relative;
z-index: 1;
}
.panel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.step-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
}
.step-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.group-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.55rem;
}
@media (min-width: 560px) {
.group-grid { grid-template-columns: 1fr 1fr; }
}
.group {
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
padding: 0.55rem 0.65rem 0.7rem;
transition: border-color 0.18s ease, background 0.18s ease;
}
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
.group-head {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: 0;
padding: 0.15rem 0.1rem 0.4rem;
cursor: pointer;
color: var(--color-foreground);
font-family: inherit;
}
.group-icon { color: var(--color-primary); display: inline-flex; }
.group-title {
font-size: 0.74rem;
font-weight: 500;
letter-spacing: -0.005em;
flex: 1;
text-align: left;
}
.group-state {
display: inline-flex;
color: var(--color-muted-foreground);
}
.group-all .group-state { color: var(--color-primary); }
.group-some .group-state { color: var(--color-citrus); }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.chip {
font-size: 0.7rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
.chip-on {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
color: var(--color-foreground);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.warn-strip {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.55rem 0.75rem 0.55rem 1rem;
border-radius: 10px;
font-size: 0.72rem;
line-height: 1.4;
color: var(--color-error-fg);
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
overflow: hidden;
}
.warn-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--color-coral);
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
}
</style>
@@ -0,0 +1,603 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
type ConflictMode = 'skip' | 'rename' | 'overwrite';
interface ValidationResult {
valid: boolean;
entity_counts?: Record<string, number>;
warnings?: string[];
errors?: string[];
}
interface ImportResult {
created?: number;
skipped?: number;
overwritten?: number;
errors?: string[];
warnings?: string[];
}
interface Props {
importFile: File | null;
importConflict: ConflictMode;
validating: boolean;
validationResult: ValidationResult | null;
importing: boolean;
importResult: ImportResult | null;
onFileSelect: (file: File | null) => void;
onConflictChange: (mode: ConflictMode) => void;
onValidate: () => void;
onImport: () => void;
}
let {
importFile,
importConflict,
validating,
validationResult,
importing,
importResult,
onFileSelect,
onConflictChange,
onValidate,
onImport,
}: Props = $props();
let dragging = $state(false);
let inputEl = $state<HTMLInputElement | undefined>();
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
];
function pickFile(): void {
inputEl?.click();
}
function handleInput(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
onFileSelect(file);
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files?.[0];
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
onFileSelect(file);
}
}
function handleDragOver(e: DragEvent): void {
e.preventDefault();
dragging = true;
}
function handleDragLeave(): void {
dragging = false;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const entityCount = $derived(
validationResult?.entity_counts
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
: 0
);
</script>
<section class="import-panel glass">
<header class="panel-head">
<div class="panel-eyebrow">
<MdiIcon name="mdiDatabaseImport" size={14} />
<span>{t('backup.import')}</span>
</div>
<h3 class="panel-title">{t('backup.importDescription')}</h3>
</header>
<div class="panel-body">
<!-- Step 1: file -->
<div class="step">
<div class="step-head">
<span class="step-num">01</span>
<span class="step-label">{t('backup.stepFile')}</span>
</div>
{#if importFile}
<div class="file-pill">
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
<div class="file-meta">
<div class="file-name" title={importFile.name}>{importFile.name}</div>
<div class="file-size">{formatBytes(importFile.size)}</div>
</div>
<button class="file-change" type="button" onclick={pickFile}>
<MdiIcon name="mdiSwapHorizontal" size={14} />
<span>{t('backup.changeFile')}</span>
</button>
</div>
{:else}
<button type="button"
class="dropzone"
class:dropzone-active={dragging}
onclick={pickFile}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}>
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
<span class="dropzone-text">
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
</span>
</button>
{/if}
<input bind:this={inputEl} type="file" accept=".json,application/json"
class="visually-hidden" onchange={handleInput} />
</div>
<!-- Step 2: validate -->
{#if importFile}
<div class="step">
<div class="step-head">
<span class="step-num">02</span>
<span class="step-label">{t('backup.stepValidate')}</span>
{#if validationResult}
<span class="validate-pill"
class:validate-ok={validationResult.valid}
class:validate-bad={!validationResult.valid}>
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
</span>
{/if}
</div>
{#if !validationResult}
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
{#if validating}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
{/if}
{validating ? t('backup.validating') : t('backup.validateBtn')}
</Button>
{:else}
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
{#if entityCount > 0}
<div class="validate-summary">
<span class="validate-count font-mono">{entityCount}</span>
<span class="validate-count-label">{t('backup.entities')}</span>
</div>
<div class="validate-categories">
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
<span class="validate-cat">
<span class="validate-cat-num font-mono">{count}</span>
<span class="validate-cat-name">{cat}</span>
</span>
{/each}
</div>
{/if}
{#if validationResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each validationResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
{#if validationResult.errors?.length}
<ul class="validate-list validate-err">
{#each validationResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Step 3: conflict mode -->
{#if importFile && validationResult?.valid}
<div class="step">
<div class="step-head">
<span class="step-num">03</span>
<span class="step-label">{t('backup.stepConflict')}</span>
</div>
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
{#each conflictOptions as opt}
<button type="button"
role="radio"
aria-checked={importConflict === opt.value}
class="seg"
class:seg-on={importConflict === opt.value}
onclick={() => onConflictChange(opt.value)}>
<MdiIcon name={opt.icon} size={14} />
<span>{t(opt.labelKey)}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Step 4: CTA + results -->
<div class="step step-cta">
{#if importFile && !validationResult?.valid && !validating}
<div class="cta-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('backup.validateFirst')}</span>
</div>
{/if}
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
{#if importing}
<MdiIcon name="mdiLoading" size={14} />
{:else}
<MdiIcon name="mdiUpload" size={14} />
{/if}
{importing ? t('backup.importing') : t('backup.importBtn')}
</Button>
{#if importResult}
<div class="import-results">
<div class="result-tiles">
<div class="result-tile tile-created">
<span class="result-num font-mono">{importResult.created ?? 0}</span>
<span class="result-label">{t('backup.resultCreated')}</span>
</div>
<div class="result-tile tile-skipped">
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
<span class="result-label">{t('backup.resultSkipped')}</span>
</div>
<div class="result-tile tile-overwritten">
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
<span class="result-label">{t('backup.resultOverwritten')}</span>
</div>
</div>
{#if importResult.errors?.length}
<ul class="validate-list validate-err">
{#each importResult.errors as e}
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
{/each}
</ul>
{/if}
{#if importResult.warnings?.length}
<ul class="validate-list validate-warn">
{#each importResult.warnings as w}
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
</section>
<style>
.import-panel {
padding: 1.5rem 1.5rem 1.35rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
min-height: 100%;
}
.panel-head { position: relative; z-index: 1; }
.panel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.panel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 36ch;
}
.panel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
.step { display: flex; flex-direction: column; gap: 0.6rem; }
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
.step-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.step-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
}
/* Drop zone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 1.65rem 1.1rem;
border-radius: 16px;
border: 1.5px dashed var(--color-rule-strong);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
text-align: center;
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
min-height: 140px;
}
.dropzone:hover {
color: var(--color-foreground);
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.dropzone-active {
color: var(--color-foreground);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
transform: scale(1.005);
}
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
.dropzone-text { line-height: 1.4; max-width: 28ch; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* File pill */
.file-pill {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
}
.file-icon { color: var(--color-primary); flex-shrink: 0; }
.file-meta { flex: 1; min-width: 0; }
.file-name {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.66rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.file-change {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.32rem 0.65rem;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.7rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
/* Validation */
.validate-pill {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-weight: 500;
}
.validate-ok {
color: var(--color-success-fg);
background: var(--color-success-bg);
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
}
.validate-bad {
color: var(--color-error-fg);
background: var(--color-error-bg);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
}
.validate-card {
padding: 0.7rem 0.85rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.validate-card-bad {
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
}
.validate-summary {
display: flex;
align-items: baseline;
gap: 0.45rem;
}
.validate-count {
font-size: 1.4rem;
font-weight: 500;
color: var(--color-foreground);
line-height: 1;
}
.validate-count-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.validate-categories {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.validate-cat {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass);
font-size: 0.66rem;
}
.validate-cat-num {
color: var(--color-primary);
font-weight: 500;
}
.validate-cat-name {
color: var(--color-muted-foreground);
}
.validate-list {
list-style: none;
padding: 0; margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.validate-list li {
display: flex;
align-items: flex-start;
gap: 0.35rem;
font-size: 0.7rem;
line-height: 1.4;
}
.validate-warn li { color: var(--color-warning-fg); }
.validate-err li { color: var(--color-error-fg); }
/* Segmented (same vocabulary as ExportPanel) */
.segmented {
display: grid;
grid-template-columns: 1fr;
gap: 0.4rem;
}
@media (min-width: 480px) {
.segmented { grid-template-columns: repeat(3, 1fr); }
}
.seg {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.55rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
cursor: pointer;
font-family: inherit;
font-size: 0.72rem;
text-align: left;
line-height: 1.25;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
}
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
.seg-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-foreground);
box-shadow:
inset 0 1px 0 var(--color-highlight),
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.cta-hint {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-bottom: 0.5rem;
}
.step-cta {
margin-top: auto;
padding-top: 0.4rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.65rem;
}
.import-results {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.result-tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
}
.result-tile {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.6rem 0.7rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.result-num {
font-size: 1.2rem;
font-weight: 500;
line-height: 1;
color: var(--color-foreground);
}
.result-label {
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted-foreground);
}
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
.tile-created .result-num { color: var(--color-mint); }
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
.tile-skipped .result-num { color: var(--color-sky); }
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
.tile-overwritten .result-num { color: var(--color-citrus); }
</style>
@@ -0,0 +1,136 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
interface PendingState {
pending: boolean;
uploaded_at?: string | null;
uploaded_by?: string | null;
conflict_mode?: string;
supervised?: boolean;
}
interface Props {
pending: PendingState | null;
onApply: () => void;
onCancel: () => void;
}
let { pending, onApply, onCancel }: Props = $props();
</script>
{#if pending?.pending}
<div class="pending-strip animate-rise" role="alert">
<span class="pending-edge" aria-hidden="true"></span>
<span class="aurora-pulse error" aria-hidden="true"></span>
<div class="pending-body">
<div class="pending-title">
<MdiIcon name="mdiShieldAlertOutline" size={16} />
<span>{t('backup.pendingTitle')}</span>
</div>
<div class="pending-meta">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
<span class="pending-dot">·</span>
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
</div>
</div>
<div class="pending-actions">
{#if pending.supervised}
<Button size="sm" onclick={onApply}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button class="pending-cancel" onclick={onCancel} type="button">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<style>
.pending-strip {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
margin-bottom: 1.25rem;
border-radius: 18px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
box-shadow:
var(--shadow-card),
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
overflow: hidden;
flex-wrap: wrap;
}
.pending-strip::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.35;
}
.pending-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
}
.pending-body {
position: relative;
z-index: 1;
flex: 1;
min-width: 12rem;
}
.pending-title {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.01em;
}
.pending-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
margin-top: 0.18rem;
word-break: break-word;
}
.pending-dot {
opacity: 0.6;
margin: 0 0.25rem;
}
.pending-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.pending-cancel {
padding: 0 0.95rem;
height: 34px;
font-size: 0.82rem;
border-radius: 12px;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.pending-cancel:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
border-color: var(--color-rule-strong);
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
interface Props {
enabled: boolean;
intervalHours: string;
secretsMode: string;
retentionCount: string;
saving: boolean;
onToggle: () => void;
onSave: () => void;
}
let {
enabled,
intervalHours = $bindable(),
secretsMode = $bindable(),
retentionCount = $bindable(),
saving,
onToggle,
onSave,
}: Props = $props();
const intervalItems: GridItem[] = $derived([
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
]);
const secretsItems: GridItem[] = $derived([
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
]);
const retentionItems: GridItem[] = $derived([
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
]);
</script>
<section class="cassette glass" class:cassette-on={enabled}>
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
<span class="toggle-track" class:toggle-on={enabled}>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">
<span class="cassette-eyebrow">
<MdiIcon name="mdiClockOutline" size={12} />
<span>{t('backup.scheduled')}</span>
</span>
<span class="cassette-title">{t('backup.enableScheduled')}</span>
</span>
</button>
{#if enabled}
<div class="cassette-controls">
<div class="ctl">
<span class="ctl-label">{t('backup.interval')}</span>
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.secretsMode')}</span>
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
</div>
<div class="ctl">
<span class="ctl-label">{t('backup.retention')}</span>
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
</div>
</div>
{:else}
<div class="cassette-off">{t('backup.scheduleOff')}</div>
{/if}
<div class="cassette-save">
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
<MdiIcon name="mdiContentSave" size={14} />
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
</section>
<style>
.cassette {
display: flex;
align-items: stretch;
gap: 1.1rem;
padding: 0.95rem 1.15rem;
flex-wrap: wrap;
}
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
.cassette-toggle {
display: flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
cursor: pointer;
font-family: inherit;
color: var(--color-foreground);
text-align: left;
padding: 0.2rem 0.1rem;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
.cassette-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.cassette-title {
font-size: 0.85rem;
font-weight: 500;
font-family: var(--font-display);
font-style: italic;
letter-spacing: -0.005em;
color: var(--color-foreground);
}
.cassette-controls {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem;
flex: 1;
min-width: 0;
}
@media (min-width: 720px) {
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.ctl-label {
font-family: var(--font-mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--color-muted-foreground);
}
.cassette-off {
flex: 1;
display: flex;
align-items: center;
font-size: 0.78rem;
color: var(--color-muted-foreground);
font-style: italic;
font-family: var(--font-display);
position: relative;
z-index: 1;
}
.cassette-save {
display: flex;
align-items: flex-end;
position: relative;
z-index: 1;
flex-shrink: 0;
}
@media (max-width: 720px) {
.cassette-save { width: 100%; }
.cassette-save > :global(*) { width: 100%; }
}
</style>
+369 -51
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
@@ -13,7 +15,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -22,6 +24,7 @@
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
@@ -92,6 +95,53 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
tiles.push({
icon: TYPE_ICONS[target.type] || 'mdiTarget',
label: target.type,
tone: 'lavender',
mono: true,
});
const botName = getBotName(target);
if (botName) {
tiles.push({
icon: 'mdiRobot',
label: botName,
tone: 'sky',
});
}
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
icon: 'mdiChat',
label: String(cfg.chat_id),
tone: 'orchid',
mono: true,
});
}
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiLinkVariant',
label: host,
hint: String(cfg.url),
href: String(cfg.url),
tone: 'orchid',
mono: true,
});
}
return tiles;
}
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
@@ -164,6 +214,20 @@
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// Per-target expansion state for the receivers section. Hidden by default.
let expandedTargets = $state<Set<number>>(new SvelteSet());
function isExpanded(id: number): boolean {
return expandedTargets.has(id);
}
function toggleExpanded(id: number) {
if (expandedTargets.has(id)) expandedTargets.delete(id);
else expandedTargets.add(id);
}
function expandTarget(id: number) {
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// Reset form when switching target type tabs
@@ -179,6 +243,98 @@
onMount(load);
// ── Bot grouping ──
type TargetGroup = {
key: string;
type: string;
name: string;
subtitle: string | null;
icon: string;
typeBadge: string | null;
botHref: string | null;
botEntityId: number | null;
muted: boolean;
targets: NotificationTarget[];
};
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
const groupedTargets = $derived.by<TargetGroup[]>(() => {
const groups = new Map<string, TargetGroup>();
for (const tgt of targets) {
const isBotType = BOT_TYPES.has(tgt.type);
const botId = isBotType ? getBotEntityId(tgt) : null;
const key = isBotType
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
: `${tgt.type}:direct`;
let group = groups.get(key);
if (!group) {
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
let name = '';
let subtitle: string | null = null;
let muted = false;
if (isBotType && botId) {
if (tgt.type === 'telegram') {
const bot = telegramBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
icon = bot?.icon || 'mdiSend';
} else if (tgt.type === 'email') {
const bot = emailBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.email || null;
icon = bot?.icon || 'mdiEmailOutline';
} else if (tgt.type === 'matrix') {
const bot = matrixBots.find(b => b.id === botId);
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
subtitle = bot?.display_name || bot?.homeserver_url || null;
icon = bot?.icon || 'mdiMatrix';
}
} else if (isBotType) {
name = t('targets.groupNoBot');
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
muted = true;
} else {
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
subtitle = t('targets.groupDirect');
muted = true;
}
group = {
key,
type: tgt.type,
name,
subtitle,
icon,
typeBadge,
botHref: isBotType && botId ? getBotHref(tgt) : null,
botEntityId: isBotType ? botId : null,
muted,
targets: [],
};
groups.set(key, group);
}
group.targets.push(tgt);
}
const rank = (g: TargetGroup) => {
if (g.type === 'broadcast') return 4;
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
return 1; // bot-linked
};
return [...groups.values()].sort((a, b) => {
const ra = rank(a), rb = rank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name);
});
});
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
@@ -216,6 +372,16 @@
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
async function discoverReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
function openNew() {
@@ -341,15 +507,27 @@
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
if (addingReceiverForTarget === targetId) {
addingReceiverForTarget = null;
await tick();
}
addingReceiverForTarget = targetId;
expandTarget(targetId);
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
// Show what we have immediately (cached list), then actively discover in the
// background so any newly-added chats appear in the palette as soon as
// Telegram returns them.
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
if (botId) {
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
discoverReceiverBotChats(botId);
}
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
@@ -453,7 +631,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
crumb={t('crumbs.routingTargets')}
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
@@ -510,53 +688,85 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.type === 'broadcast' && target.child_targets?.length}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list -->
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
<div class="targets-list">
{#each groupedTargets as group (group.key)}
<section class="target-group">
<BotGroupHeader
icon={group.icon}
name={group.name}
subtitle={group.subtitle}
targetCount={group.targets.length}
typeBadge={!activeType ? group.typeBadge : null}
botHref={group.botHref}
botEntityId={group.botEntityId}
muted={group.muted}
/>
</Card>
<div class="target-group__items stagger-children">
{#each group.targets as target (target.id)}
{@const expanded = isExpanded(target.id)}
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center gap-2">
<button
type="button"
class="target-summary"
aria-expanded={expanded}
aria-controls={`target-body-${target.id}`}
onclick={() => toggleExpanded(target.id)}
>
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
<MdiIcon name="mdiChevronRight" size={16} />
</span>
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<span class="target-summary__name">{target.name}</span>
{#if childCount > 0}
<span class="target-summary__count">
<span class="target-summary__count-num">{childCount}</span>
<span class="target-summary__count-label">{childLabel}</span>
</span>
{:else}
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
{/if}
</button>
<MetaStrip tiles={targetTiles(target)} />
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list (collapsible) -->
{#if expanded}
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</div>
{/if}
</Card>
{/each}
</div>
</section>
{/each}
</div>
{/if}
@@ -578,3 +788,111 @@
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.targets-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.target-group {
display: block;
}
.target-group__items {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-left: 0.85rem;
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
margin-left: 0.55rem;
}
@media (max-width: 640px) {
.target-group__items {
padding-left: 0.4rem;
margin-left: 0.25rem;
}
}
.target-summary {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.1rem 0.25rem 0.1rem 0;
margin: -0.1rem 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: 8px;
transition: background 0.15s ease;
}
@media (min-width: 1024px) {
.target-summary {
flex: 0 1 auto;
max-width: 32rem;
}
}
.target-summary:hover {
background: var(--color-glass-strong);
}
.target-summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.target-summary__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
}
.target-summary__chevron.open {
transform: rotate(90deg);
color: var(--color-primary);
}
.target-summary__icon {
color: var(--color-primary);
display: inline-flex;
flex-shrink: 0;
}
.target-summary__name {
font-weight: 500;
font-size: 0.95rem;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.target-summary__count {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.12rem 0.45rem;
border-radius: 9999px;
background: var(--color-muted);
flex-shrink: 0;
}
.target-summary__count-num {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-foreground);
}
.target-summary__count-label {
text-transform: lowercase;
}
.target-summary__count--empty {
font-style: italic;
font-family: inherit;
font-size: 0.7rem;
color: var(--color-muted-foreground);
background: transparent;
padding: 0.12rem 0.2rem;
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts">
import MdiIcon from '$lib/components/MdiIcon.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { t } from '$lib/i18n';
interface Props {
icon: string;
name: string;
subtitle?: string | null;
targetCount: number;
typeBadge?: string | null;
botHref?: string | null;
botEntityId?: number | null;
muted?: boolean;
}
let {
icon,
name,
subtitle = null,
targetCount,
typeBadge = null,
botHref = null,
botEntityId = null,
muted = false,
}: Props = $props();
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
</script>
<div class="bot-group-header" class:muted>
<div class="bot-avatar">
<MdiIcon name={icon} size={18} />
</div>
<div class="bot-meta">
<div class="bot-title-row">
<span class="bot-name">{name}</span>
{#if typeBadge}
<span class="type-badge">{typeBadge}</span>
{/if}
</div>
{#if subtitle}
<span class="bot-sub">{subtitle}</span>
{/if}
</div>
<div class="bot-actions">
<span class="count-chip">
<span class="count-num">{targetCount}</span>
<span class="count-label">{countLabel}</span>
</span>
{#if botHref}
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
{/if}
</div>
</div>
<style>
.bot-group-header {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
margin: 1.4rem 0 0.55rem 0;
border-radius: 14px;
background: linear-gradient(
95deg,
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
var(--color-glass) 75%
);
border: 1px solid var(--color-rule-strong);
backdrop-filter: blur(18px) saturate(150%);
-webkit-backdrop-filter: blur(18px) saturate(150%);
overflow: hidden;
}
.bot-group-header::before {
content: '';
position: absolute;
left: 0;
top: 12%;
bottom: 12%;
width: 3px;
border-radius: 0 4px 4px 0;
background: linear-gradient(
180deg,
var(--color-primary),
color-mix(in srgb, var(--color-primary) 35%, transparent)
);
}
.bot-group-header.muted {
background: var(--color-glass);
}
.bot-group-header.muted::before {
background: var(--color-rule-strong);
}
.bot-avatar {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.muted .bot-avatar {
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
border-color: var(--color-rule-strong);
}
.bot-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.bot-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.bot-name {
font-size: 0.92rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.type-badge {
font-size: 0.6rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--color-muted);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.bot-sub {
font-size: 0.7rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bot-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.count-chip {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.18rem 0.5rem;
border-radius: 9999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
}
.count-num {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
}
.count-label {
text-transform: lowercase;
}
.bot-group-header:first-child {
margin-top: 0;
}
</style>
@@ -114,34 +114,37 @@
</div>
{/each}
<!-- Inline add-receiver form -->
{#if addingReceiverForTarget === target.id}
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if addingReceiverForTarget === target.id}
<EntitySelect
items={chatItems}
bind:value={receiverForm.chat_id}
open={true}
showTrigger={false}
placeholder={t('telegramBot.selectChat')}
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
onclose={oncancelReceiver}
/>
{/if}
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
<MdiIcon name="mdiPlus" size={14} />
{t('targets.addReceiver')}
</button>
{:else if addingReceiverForTarget === target.id}
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
{#if target.type === 'telegram'}
{@const botId = target.config?.bot_id}
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
{#if chatItems.length > 0}
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
{:else}
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
{#if botId}
<button type="button" onclick={() => onloadBotChats(botId)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</button>
{/if}
{:else if target.type === 'email'}
{#if target.type === 'email'}
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if target.type === 'webhook'}
@@ -28,6 +28,7 @@
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';
@@ -261,7 +262,25 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
// config in edit mode. Mirrors the same hook on tracking-configs so the
// Notification Tracker form can link directly to the editor instead of
// the generic list. Strips the param afterwards so a browser refresh
// doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allTemplateConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
}
/**
@@ -408,6 +427,45 @@
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 = {
@@ -429,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}
@@ -609,24 +667,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={templateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -26,6 +26,7 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -238,6 +239,38 @@
window.history.replaceState(null, '', cleanUrl);
}
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 };
@@ -276,7 +309,7 @@
title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')}
crumb="Routing · Notification"
crumb={t('crumbs.routingNotification')}
count={configs.length}
countLabel={t('trackingConfig.countLabel')}
pills={headerPills}
@@ -448,25 +481,26 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
{@const desc = getDescriptor(config.provider_type)}
<Card hover entityId={config.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={trackingConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
+34 -7
View File
@@ -14,6 +14,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
@@ -87,13 +88,38 @@
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb="System · Access"
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
@@ -133,15 +159,16 @@
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.7.0"
version = "0.8.0"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -0,0 +1,32 @@
"""Upstream release-check providers.
This package is intentionally separate from :mod:`notify_bridge_core.providers`:
* service providers are user-configured entities persisted per-tenant in the DB;
* release providers are admin-level upstream-version probes selected by setting,
with at most one active provider per installation.
Mixing them in one enum/factory bled responsibilities and complicated future
additions (e.g. a GitHub release provider that has nothing to do with Gitea
service integrations).
"""
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProvider,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
)
from .registry import build_release_provider
__all__ = [
"ReleaseErrorCode",
"ReleaseInfo",
"ReleaseProvider",
"ReleaseProviderKind",
"ReleaseTestResult",
"build_release_provider",
"is_valid_repo",
]
@@ -0,0 +1,156 @@
"""ReleaseProvider abstraction and shared tag/version utilities."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Protocol, TypedDict, runtime_checkable
class ReleaseProviderKind(str, Enum):
"""Supported upstream release-check providers."""
DISABLED = "disabled"
GITEA = "gitea"
GITHUB = "github"
# Single source of truth for `release_error` taxonomy. Surfaced into the cached
# `AppSetting`, returned via the API, and translated by the frontend.
class ReleaseErrorCode(str, Enum):
DISABLED = "disabled"
MISCONFIGURED = "misconfigured"
PROVIDER_CHANGED = "provider_changed"
NO_RELEASE_FOUND = "no_release_found"
NETWORK_ERROR = "network_error"
HTTP_ERROR = "http_error"
PARSE_ERROR = "parse_error"
UNSAFE_URL = "unsafe_url"
NOT_IMPLEMENTED = "not_implemented"
UNKNOWN_ERROR = "unknown_error"
@dataclass(frozen=True)
class ReleaseInfo:
"""Normalised release metadata returned by a provider."""
tag: str
version: str
name: str | None = None
body: str | None = None
url: str | None = None
published_at: str | None = None
prerelease: bool = False
draft: bool = False
class ReleaseTestResult(TypedDict):
"""Structured shape returned by :meth:`ReleaseProvider.test`."""
ok: bool
info: ReleaseInfo | None
error: str | None
@runtime_checkable
class ReleaseProvider(Protocol):
"""Protocol implemented by every release provider.
Implementations are expected to be safe to instantiate without external
side effects connectivity is deferred until :meth:`fetch_latest` or
:meth:`test` is awaited.
"""
kind: ClassVar[ReleaseProviderKind]
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
"""Return the latest release, or ``None`` if there is nothing to report."""
async def test(self) -> ReleaseTestResult:
"""Probe the upstream and return a structured status payload."""
# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
def is_valid_repo(repo: str) -> bool:
"""``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""
return bool(repo) and _REPO_RE.match(repo) is not None
_TAG_NUMERIC = re.compile(r"\d+")
# Stop reading numeric segments at the first non-digit-non-dot character so
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
_HEAD_SPLIT = re.compile(r"[^0-9.]")
def normalise_version(tag: str) -> str:
"""Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""
if not tag:
return ""
cleaned = tag.strip()
if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
cleaned = cleaned[1:]
return cleaned
def _split_version(version: str) -> tuple[tuple[int, ...], str]:
"""Split a version into (numeric segments, prerelease suffix).
A non-empty prerelease suffix marks the version as pre-stable. We use it
as a tie-break only when numeric segments are equal a stable build
sorts strictly newer than its pre-release counterpart (``0.7.2`` >
``0.7.2-rc1``), which prevents the badge from flickering between
"up to date" and "downgrade available" on installs that ship the GA.
"""
if not version:
return (), ""
work = version.split("+", 1)[0]
if "-" in work:
head, _, suffix = work.partition("-")
else:
# Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
# first non-digit-non-dot is treated as the suffix.
m = _HEAD_SPLIT.search(work)
if m and m.start() > 0:
head, suffix = work[: m.start()], work[m.start():]
else:
head, suffix = work, ""
segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
return segments, suffix.strip()
def compare_versions(a: str, b: str) -> int:
"""Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.
Numeric segments win. When numerically equal, *stable* (no suffix) beats
*prerelease* (any non-empty suffix); two equally-prereleased versions
compare equal we deliberately do not order ``rc2`` over ``rc1`` because
that requires real semver parsing and would only matter for downgrades.
"""
sa, suffix_a = _split_version(normalise_version(a))
sb, suffix_b = _split_version(normalise_version(b))
length = max(len(sa), len(sb))
for i in range(length):
x = sa[i] if i < len(sa) else 0
y = sb[i] if i < len(sb) else 0
if x != y:
return 1 if x > y else -1
# Equal numerics — stable beats prerelease.
if not suffix_a and suffix_b:
return 1
if suffix_a and not suffix_b:
return -1
return 0
def is_newer(candidate: str, baseline: str) -> bool:
"""``True`` when ``candidate`` is strictly newer than ``baseline``."""
return compare_versions(candidate, baseline) > 0
@@ -0,0 +1,167 @@
"""Gitea release provider — queries ``/api/v1/repos/{owner}/{repo}/releases``."""
from __future__ import annotations
import asyncio
import logging
from typing import ClassVar
import aiohttp
from ..notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
normalise_version,
)
_LOGGER = logging.getLogger(__name__)
# Cap upstream response body — release lists are normally a few KB; anything
# beyond this is either a misconfigured target or a malicious payload.
_MAX_BODY_BYTES = 1_000_000
class GiteaReleaseProvider:
"""Anonymous Gitea release probe.
Hits the ``releases`` endpoint (not ``releases/latest``) because the latter
skips pre-releases unconditionally we want to honour the caller's
``include_prereleases`` flag instead of relying on Gitea's filtering.
"""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITEA
def __init__(self, session: aiohttp.ClientSession, url: str, repo: str) -> None:
if not url:
raise ValueError("Gitea release provider requires a base URL")
if not is_valid_repo(repo):
raise ValueError(
"Gitea release provider requires repo as 'owner/name' "
"(alphanumerics, dot, dash, underscore only)"
)
self._session = session
self._url = url.rstrip("/")
self._repo = repo.strip("/")
@property
def _endpoint(self) -> str:
return f"{self._url}/api/v1/repos/{self._repo}/releases"
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError as err:
_LOGGER.warning("Gitea release URL rejected by SSRF guard: %s", err)
return None
try:
async with self._session.get(
self._endpoint,
params={"limit": "20", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
_LOGGER.warning(
"Gitea releases fetch failed: HTTP %s for %s",
response.status, self._endpoint,
)
return None
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response exceeded %d bytes — refusing to parse",
_MAX_BODY_BYTES,
)
return None
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Gitea releases fetch error: %s", err)
return None
except (ValueError, UnicodeDecodeError) as err:
_LOGGER.warning("Gitea releases parse error: %s", err)
return None
if not isinstance(payload, list):
return None
for entry in payload:
if not isinstance(entry, dict):
continue
if entry.get("draft"):
continue
if entry.get("prerelease") and not include_prereleases:
continue
return _to_release_info(entry)
return None
async def test(self) -> ReleaseTestResult:
# Validate URL first so the "test" button surfaces an SSRF rejection
# to the operator rather than silently returning "unreachable".
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
async with self._session.get(
self._endpoint,
params={"limit": "1", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
return {"ok": False, "info": None, "error": ReleaseErrorCode.HTTP_ERROR.value}
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.NETWORK_ERROR.value}
except (ValueError, UnicodeDecodeError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
if not isinstance(payload, list) or not payload:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NO_RELEASE_FOUND.value}
first = payload[0]
if not isinstance(first, dict):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
return {"ok": True, "info": _to_release_info(first), "error": None}
def _to_release_info(entry: dict) -> ReleaseInfo:
tag = str(entry.get("tag_name") or "").strip()
return ReleaseInfo(
tag=tag,
version=normalise_version(tag),
name=entry.get("name") or None,
body=entry.get("body") or None,
url=entry.get("html_url") or None,
published_at=entry.get("published_at") or entry.get("created_at") or None,
prerelease=bool(entry.get("prerelease", False)),
draft=bool(entry.get("draft", False)),
)
@@ -0,0 +1,34 @@
"""GitHub release provider stub.
Reserved so the registry advertises the option and the frontend can render the
provider toggle without a follow-up backend release. The full implementation
will mirror :class:`GiteaReleaseProvider` against
``api.github.com/repos/{owner}/{repo}/releases``.
"""
from __future__ import annotations
from typing import ClassVar
import aiohttp
from .base import ReleaseErrorCode, ReleaseInfo, ReleaseProviderKind, ReleaseTestResult
class GitHubReleaseProvider:
"""Not yet implemented — placeholder so the registry is forward-compatible."""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITHUB
def __init__(self, session: aiohttp.ClientSession, repo: str) -> None:
self._session = session
self._repo = repo
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
# Soft-fail rather than raise — `run_check` already catches
# NotImplementedError but a None return keeps the persisted
# `release_error` taxonomy clean (NOT_IMPLEMENTED, not "not impl…").
return None
async def test(self) -> ReleaseTestResult:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NOT_IMPLEMENTED.value}
@@ -0,0 +1,51 @@
"""Factory for release providers — single entry point for callers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ReleaseProvider, ReleaseProviderKind, is_valid_repo
from .gitea import GiteaReleaseProvider
from .github import GitHubReleaseProvider
if TYPE_CHECKING:
import aiohttp
def build_release_provider(
kind: str | ReleaseProviderKind,
*,
session: aiohttp.ClientSession,
url: str = "",
repo: str = "",
) -> ReleaseProvider | None:
"""Build a release provider for the given kind.
Returns ``None`` when disabled or when required configuration is missing
or unsafe (invalid repo format, empty URL) callers treat the absence as
"no checks performed" without branching on the kind string everywhere.
"""
try:
normalised = (
ReleaseProviderKind(kind)
if not isinstance(kind, ReleaseProviderKind)
else kind
)
except ValueError:
return None
if normalised is ReleaseProviderKind.DISABLED:
return None
if normalised is ReleaseProviderKind.GITEA:
if not url or not is_valid_repo(repo):
return None
try:
return GiteaReleaseProvider(session=session, url=url, repo=repo)
except ValueError:
return None
if normalised is ReleaseProviderKind.GITHUB:
if not is_valid_repo(repo):
return None
return GitHubReleaseProvider(session=session, repo=repo)
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.7.0"
version = "0.8.0"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -2,13 +2,18 @@
import logging
import os
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from notify_bridge_core.release import ReleaseProviderKind, is_valid_repo
from ..auth.dependencies import get_current_user, require_admin
from ..auth.routes import limiter # shared SlowAPI instance (app.state.limiter)
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -28,6 +33,12 @@ _SETTING_KEYS = {
"log_level": "NOTIFY_BRIDGE_LOG_LEVEL", # DEBUG/INFO/WARNING/ERROR
"log_format": "NOTIFY_BRIDGE_LOG_FORMAT", # text|json (requires restart to switch)
"log_levels": "NOTIFY_BRIDGE_LOG_LEVELS", # module=LEVEL,module2=LEVEL
# Release-check — see services/release_check.py for the cached-state keys.
"release_provider_kind": "NOTIFY_BRIDGE_RELEASE_PROVIDER", # disabled|gitea|github
"release_provider_url": "NOTIFY_BRIDGE_RELEASE_PROVIDER_URL",
"release_provider_repo": "NOTIFY_BRIDGE_RELEASE_PROVIDER_REPO",
"release_include_prereleases": None, # "0"|"1"
"release_check_interval_hours": None, # 1..168
}
_DEFAULTS = {
@@ -42,6 +53,13 @@ _DEFAULTS = {
"log_level": "INFO",
"log_format": "text",
"log_levels": "",
# Pre-seed Gitea release checks against this repo's own upstream so a fresh
# install knows where to look without operator intervention.
"release_provider_kind": "gitea",
"release_provider_url": "https://git.dolgolyov-family.by",
"release_provider_repo": "alexei.dolgolyov/notify-bridge",
"release_include_prereleases": "0",
"release_check_interval_hours": "12",
}
# Settings whose changes require dropping in-memory Telegram caches so the
@@ -53,6 +71,17 @@ _CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_ent
# changing it means swapping the handler formatter entirely.
_LOG_SETTING_KEYS = {"log_level", "log_levels", "log_format"}
# Release-check settings whose change must trigger cache invalidation (so a
# stale "latest version" doesn't linger after pointing at a new repo) and a
# scheduler re-arm so the new interval/provider takes effect immediately.
_RELEASE_PROVIDER_KEYS = {
"release_provider_kind",
"release_provider_url",
"release_provider_repo",
"release_include_prereleases",
}
_RELEASE_INTERVAL_KEY = "release_check_interval_hours"
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
@@ -81,6 +110,11 @@ class SettingsUpdate(BaseModel):
log_level: str | None = None
log_format: str | None = None
log_levels: str | None = None
release_provider_kind: str | None = None
release_provider_url: str | None = None
release_provider_repo: str | None = None
release_include_prereleases: bool | int | str | None = None
release_check_interval_hours: int | str | None = None
@router.get("")
@@ -111,12 +145,65 @@ async def update_settings(
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
old_timezone = await get_setting(session, "timezone")
old_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
old_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
old_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
for key in _SETTING_KEYS:
value = getattr(body, key, None)
if value is None:
continue
value_str = str(value)
# Normalise per-key before storing so the cache keys always hold the
# canonical wire format ("0"/"1" for bool flags, clamped int for the
# release interval). Without this, str(True) would leak "True" into the
# release_include_prereleases cell and silently disable filtering.
if key == "release_include_prereleases":
if isinstance(value, bool):
value_str = "1" if value else "0"
else:
value_str = "1" if str(value).strip().lower() in ("1", "true", "yes", "on") else "0"
elif key == "release_check_interval_hours":
from ..services.release_check import parse_interval_hours
value_str = str(parse_interval_hours(str(value)))
elif key == "release_provider_kind":
# Reject anything outside the enum so a typo doesn't leave the DB
# in a state the service can't interpret.
value_str = str(value).strip().lower()
try:
value_str = ReleaseProviderKind(value_str).value
except ValueError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_kind: {value_str!r}",
) from err
elif key == "release_provider_url":
value_str = str(value).strip()
if value_str:
# Reject embedded userinfo (http://user:pass@host) so the
# GET /settings response can never echo credentials back, and
# block private/loopback/metadata targets via the SSRF guard.
parsed = urlparse(value_str)
if parsed.username or parsed.password:
raise HTTPException(
status_code=400,
detail="release_provider_url must not contain credentials",
)
try:
await avalidate_outbound_url(value_str)
except UnsafeURLError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_url: {err}",
) from err
elif key == "release_provider_repo":
value_str = str(value).strip()
if value_str and not is_valid_repo(value_str):
raise HTTPException(
status_code=400,
detail="release_provider_repo must match 'owner/name' "
"(alphanumerics, dot, dash, underscore only)",
)
else:
value_str = str(value)
# GET masks the webhook secret as "***<last4>" so the real value is
# never exposed to the frontend. If the client sends the mask back
# (which happens on every save, since bind:value holds whatever GET
@@ -182,6 +269,27 @@ async def update_settings(
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
await _reregister_webhooks(session, new_base_url, new_secret)
# Release-check: clear stale cache when the provider repo/url/kind changes,
# and re-arm the periodic job whenever the interval or provider changes.
new_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
new_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
release_provider_changed = new_release_values != old_release_values
release_interval_changed = new_release_interval != old_release_interval
if release_provider_changed:
from datetime import datetime, timezone
from notify_bridge_core.release import ReleaseErrorCode
from ..services.release_check import persist_release_state
await persist_release_state(
checked_at=datetime.now(timezone.utc).isoformat(),
error=ReleaseErrorCode.PROVIDER_CHANGED.value,
info=None,
)
if release_provider_changed or release_interval_changed:
from ..services.scheduler import reschedule_release_check
await reschedule_release_check()
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
@@ -231,6 +339,122 @@ async def get_external_url(
return {"external_url": (await get_setting(session, "external_url")).rstrip("/")}
def _status_payload(status, *, is_admin: bool) -> dict:
"""Serialise a :class:`ReleaseStatus` for the API.
Non-admin payloads strip the upstream release body (an XSS landmine
arbitrary attacker-controlled markdown should never reach a non-admin
UI unless we explicitly sanitise it for display) and replace the raw
error string with a coarse ``error`` / ``ok`` marker so internal
hostnames from probe failures can't leak via the badge.
"""
payload = {
"provider": status.provider,
"current": status.current,
"latest": status.latest,
"latest_tag": status.latest_tag,
"latest_url": status.latest_url,
"latest_name": status.latest_name,
"latest_published_at": status.latest_published_at,
"latest_prerelease": status.latest_prerelease,
"checked_at": status.checked_at,
"update_available": status.update_available,
}
if is_admin:
payload["latest_body"] = status.latest_body
payload["error"] = status.error
else:
payload["latest_body"] = None
payload["error"] = None if not status.error else "error"
return payload
@router.get("/release")
async def get_release_status(
user: User = Depends(get_current_user),
):
"""Return the cached upstream release status (no network call).
Available to all authenticated users so the sidebar badge can render for
everyone admins manage the configuration but the awareness is global.
"""
from ..services.release_check import load_status
return _status_payload(await load_status(), is_admin=(user.role == "admin"))
@router.post("/release/check")
@limiter.limit("6/minute")
async def force_release_check(
request: Request,
user: User = Depends(require_admin),
):
"""Force an immediate upstream check and return the refreshed status."""
from ..services.release_check import run_check
status = await run_check(force=True)
return _status_payload(status, is_admin=True)
class ReleaseTestRequest(BaseModel):
provider_kind: str
provider_url: str | None = None
provider_repo: str | None = None
include_prereleases: bool | None = False
@router.post("/release/test")
@limiter.limit("12/minute")
async def test_release_provider(
request: Request,
body: ReleaseTestRequest,
user: User = Depends(require_admin),
):
"""Dry-run an arbitrary provider config — used by the cassette's Test button.
Validates the provider URL on the spot (SSRF + userinfo) so the operator
sees an actionable error before any outbound request fires.
"""
from notify_bridge_core.release import ReleaseErrorCode, build_release_provider
from ..services.http_session import get_http_session
test_url = (body.provider_url or "").strip()
test_repo = (body.provider_repo or "").strip()
if test_repo and not is_valid_repo(test_repo):
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
if test_url:
parsed = urlparse(test_url)
if parsed.username or parsed.password:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
await avalidate_outbound_url(test_url)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
http = await get_http_session()
provider = build_release_provider(
body.provider_kind,
session=http,
url=test_url,
repo=test_repo,
)
if provider is None:
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
result = await provider.test()
info = result.get("info")
info_dict = None
if info is not None:
info_dict = {
"tag": info.tag,
"version": info.version,
"name": info.name,
"url": info.url,
"published_at": info.published_at,
"prerelease": info.prerelease,
}
return {"ok": result["ok"], "info": info_dict, "error": result.get("error")}
async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str
) -> None:
@@ -403,7 +403,13 @@ async def get_command_variables(
webhook = {
"status": {
"description": "/status webhook provider summary",
"variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"},
"variables": {
**common_vars,
"trackers_active": "Number of enabled trackers attached to the webhook provider",
"trackers_total": "Total number of trackers attached to the webhook provider",
"provider_name": "Webhook provider name",
"last_event": "Last event timestamp ('YYYY-MM-DD HH:MM' or '-')",
},
},
}
@@ -118,6 +118,31 @@ async def get_status(
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
# Live-resolve command tracker and bot names for command_* events
# (mirrors the action/tracker pattern above). Falls back to the
# snapshot stored on the EventLog when the entity has been deleted.
cmd_tracker_ids = {
e.command_tracker_id for e in event_rows if e.command_tracker_id is not None
}
cmd_tracker_name_map: dict[int, str] = {}
if cmd_tracker_ids:
cmd_tracker_rows = (await session.exec(
select(CommandTracker.id, CommandTracker.name).where(
CommandTracker.id.in_(cmd_tracker_ids)
)
)).all()
cmd_tracker_name_map = {tid: tname for tid, tname in cmd_tracker_rows}
bot_ids = {
e.telegram_bot_id for e in event_rows if e.telegram_bot_id is not None
}
bot_name_map: dict[int, str] = {}
if bot_ids:
bot_rows = (await session.exec(
select(TelegramBot.id, TelegramBot.name).where(TelegramBot.id.in_(bot_ids))
)).all()
bot_name_map = {bid: bname for bid, bname in bot_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
@@ -135,11 +160,30 @@ async def get_status(
return f"(deleted) {e.action_name}"
return ""
def _display_command_tracker_name(e: EventLog) -> str:
if (
e.command_tracker_id is not None
and e.command_tracker_id in cmd_tracker_name_map
):
return cmd_tracker_name_map[e.command_tracker_id]
if e.command_tracker_name:
return f"(deleted) {e.command_tracker_name}"
return ""
def _display_bot_name(e: EventLog) -> str:
if e.telegram_bot_id is not None and e.telegram_bot_id in bot_name_map:
return bot_name_map[e.telegram_bot_id]
if e.bot_name:
return f"(deleted) {e.bot_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
For command events the ``collection_name`` already stores the
rendered ``/cmd args`` string so we just pass it through.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
@@ -155,9 +199,14 @@ async def get_status(
"id": e.id,
"event_type": e.event_type,
"collection_name": _display_subject(e),
"tracker_id": e.tracker_id,
"tracker_name": _display_tracker_name(e),
"action_id": e.action_id,
"action_name": _display_action_name(e),
"command_tracker_id": e.command_tracker_id,
"command_tracker_name": _display_command_tracker_name(e),
"telegram_bot_id": e.telegram_bot_id,
"bot_name": _display_bot_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
@@ -28,8 +28,9 @@ from ..database.models import (
WebhookPayloadLog,
)
from ..services.dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -164,7 +165,16 @@ async def _dispatch_webhook_event(
Number of successfully dispatched notifications.
"""
dispatched = 0
# ``defers_to_schedule`` is collected during the loop and flushed AFTER the
# main session commits — the only side-effect of failing to schedule is a
# delayed delivery (the startup loader / catch-up scan will reschedule),
# so this is best-effort and must not roll back the DB writes.
defers_to_schedule: set[Any] = set()
async with AsyncSession(engine) as session:
# App timezone is identical across trackers within one webhook request;
# pull it once.
app_tz = await get_app_timezone(session)
tracker_result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider_id,
@@ -173,6 +183,8 @@ async def _dispatch_webhook_event(
)
trackers = tracker_result.all()
from ..services.deferred_dispatch import defer_event, is_deferrable
for tracker in trackers:
filters = tracker.filters or {}
if not _passes_filters(event, filters):
@@ -185,11 +197,9 @@ async def _dispatch_webhook_event(
if not link_data:
continue
app_tz = await get_app_timezone(session)
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
event_log_row = EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id,
tracker_name=tracker.name,
@@ -203,18 +213,90 @@ async def _dispatch_webhook_event(
"provider_type": event.provider_type.value,
**extra_details,
},
))
)
session.add(event_log_row)
await session.flush()
event_log_id = event_log_row.id
# Dispatch to targets
# Dedupe defers by parent ``link_id``: broadcast links emit one
# ``link_data`` entry per child, all sharing the same parent id —
# the deferred row is one-per-link, so we only call ``defer_event``
# once per distinct id (earliest fire_at wins on ties).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
defers_for_event: dict[int, Any] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp event_log dispatch_status in the same
# session that holds the EventLog row, so the "deferred" badge
# only appears if the underlying queue rows actually exist.
if defers_for_event:
earliest = min(defers_for_event.values())
for link_id, fire_at in defers_for_event.items():
await defer_event(
session,
event=event,
user_id=tracker.user_id,
tracker_id=tracker.id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
details = dict(event_log_row.details or {})
if not details.get("dispatch_status"):
details["dispatch_status"] = "deferred"
details["deferred_until"] = earliest.isoformat()
event_log_row.details = details
session.add(event_log_row)
defers_to_schedule.update(defers_for_event.values())
# Dispatch to targets. Isolate dispatcher exceptions per group so
# a failed remote call doesn't bubble out, abort the surrounding
# transaction, and roll back the just-written defers/event_log.
from ..services.http_session import get_http_session
dispatcher = NotificationDispatcher(session=await get_http_session())
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
for tc, target_configs in groups.values():
if not target_configs:
continue
shaped_event = apply_tracking_display_filters(event, tc)
if shaped_event is None:
continue
results = await dispatcher.dispatch(shaped_event, target_configs)
try:
results = await dispatcher.dispatch(shaped_event, target_configs)
except Exception as err: # noqa: BLE001
_LOGGER.exception(
"Dispatcher raised for tracker %d: %s", tracker.id, err,
)
continue
for r in results:
if r.get("success"):
dispatched += 1
@@ -226,6 +308,18 @@ async def _dispatch_webhook_event(
await session.commit()
# Schedule drain jobs OUTSIDE the DB session so an APScheduler hiccup
# can't roll back the persisted defer rows.
if defers_to_schedule:
from ..services.scheduler import schedule_deferred_drain
for fire_at in defers_to_schedule:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
return dispatched
@@ -554,41 +648,3 @@ async def generic_webhook(token: str, request: Request):
await log_session.commit()
return {"ok": True, "dispatched": dispatched}
def _build_target_groups(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[tuple[Any, list[TargetConfig]]]:
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
Targets sharing a TrackingConfig dispatch together so a single
``apply_tracking_display_filters`` pass can shape one event for the
whole group; targets with different TCs may see differently-shaped
events (e.g. one with favorites_only, one without).
"""
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
return list(groups.values())
@@ -33,11 +33,13 @@ def _auto_register() -> None:
from .gitea_handler import GiteaCommandHandler
from .planka_handler import PlankaCommandHandler
from .nut_handler import NutCommandHandler
from .webhook_handler import WebhookCommandHandler
register_handler(ImmichCommandHandler())
register_handler(GiteaCommandHandler())
register_handler(PlankaCommandHandler())
register_handler(NutCommandHandler())
register_handler(WebhookCommandHandler())
# Auto-register on import
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
return sorted(enabled), merged_limits
# ---------------------------------------------------------------------------
# Event logging
# ---------------------------------------------------------------------------
def _format_command_subject(cmd: str, args: str) -> str:
"""Render the dashboard ``collection_name`` for a command event."""
args = (args or "").strip()
return f"/{cmd} {args}".rstrip() if args else f"/{cmd}"
def _normalize_issuer(issuer: dict[str, Any] | None) -> dict[str, Any] | None:
"""Strip a Telegram ``from`` payload to the fields the dashboard needs.
Telegram's ``from`` carries plenty we don't want to persist (premium
badge, language code already captured elsewhere, etc.). Keep just
the identity bits and drop anything else so future Telegram changes
can't accidentally start logging extra PII.
"""
if not issuer:
return None
keep = ("id", "username", "first_name", "last_name", "is_bot")
out = {k: issuer[k] for k in keep if k in issuer and issuer[k] not in (None, "")}
return out or None
async def _log_command_event(
*,
bot: TelegramBot,
chat_id: str,
cmd: str,
args: str,
locale: str,
event_type: str,
responses: list[CommandResponse],
ctx_tuples: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
],
extra_details: dict[str, Any] | None = None,
issuer: dict[str, Any] | None = None,
) -> None:
"""Persist a single ``EventLog`` row for a bot-command invocation.
One row per user invocation. Per-tracker breakdown lives in ``details``
(``tracker_count`` / ``responses_count``). Best-effort: a logging
failure must never block the user-visible reply, so we swallow.
"""
try:
first_tracker: CommandTracker | None = None
first_provider: ServiceProvider | None = None
if ctx_tuples:
first_tracker, _, first_provider, _ = ctx_tuples[0]
media_total = sum(len(r.media or []) for r in responses)
details: dict[str, Any] = {
"command": cmd,
"args": args or "",
"chat_id": chat_id,
"locale": locale,
"tracker_count": len(ctx_tuples),
"responses_count": len(responses),
}
normalized_issuer = _normalize_issuer(issuer)
if normalized_issuer:
details["issuer"] = normalized_issuer
if extra_details:
details.update(extra_details)
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=bot.user_id,
tracker_id=None,
tracker_name="",
action_id=None,
action_name="",
command_tracker_id=first_tracker.id if first_tracker else None,
command_tracker_name=first_tracker.name if first_tracker else "",
telegram_bot_id=bot.id,
bot_name=bot.name or "",
provider_id=first_provider.id if first_provider else None,
provider_name=(first_provider.name if first_provider else "") or "",
event_type=event_type,
collection_id=str(chat_id),
collection_name=_format_command_subject(cmd, args),
assets_count=media_total,
details=details,
))
await session.commit()
except Exception: # noqa: BLE001 — diagnostic only, never block reply
_LOGGER.exception(
"Failed to log command event bot=%d chat=%s cmd=/%s",
bot.id, chat_id, cmd,
)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
@@ -271,12 +366,18 @@ async def handle_command(
chat_id: str,
text: str,
language_code: str = "",
*,
issuer: dict[str, Any] | None = None,
) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers.
Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config.
``issuer`` is the Telegram ``from`` object (``{id, username,
first_name, last_name, language_code}``) when known. Stored on the
EventLog row so the dashboard can show *who* invoked the command.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
@@ -292,10 +393,20 @@ async def handle_command(
# Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id)
# Universal commands have no tracker/provider context.
if cmd == "start":
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=[], issuer=issuer,
)
return responses
# Unknown / disabled command — caller treats this the same as "no
# match" and we deliberately do NOT log it (avoids dashboard spam
# from random ``/foo`` traffic).
if cmd not in enabled and cmd != "start":
return None
@@ -307,13 +418,26 @@ async def handle_command(
cmd, bot.id, chat_id, wait,
)
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_rate_limited", responses=responses,
ctx_tuples=ctx_tuples, extra_details={"wait_seconds": wait},
issuer=issuer,
)
return responses
# Universal commands — single merged response
if cmd == "help":
ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
return [CommandResponse(text=text_resp)]
responses = [CommandResponse(text=text_resp)]
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=ctx_tuples, issuer=issuer,
)
return responses
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
@@ -329,48 +453,69 @@ async def handle_command(
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
dispatched_ctx: list[
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
] = []
try:
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
break
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
if result is not None:
responses.append(result)
dispatched_ctx.append((tracker, config, provider, listener))
except Exception as exc: # noqa: BLE001 — log then re-raise
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_failed", responses=responses,
ctx_tuples=ctx_tuples,
extra_details={"error": f"{type(exc).__name__}: {exc}"},
issuer=issuer,
)
if result is not None:
responses.append(result)
raise
return responses if responses else None
if responses:
await _log_command_event(
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
event_type="command_handled", responses=responses,
ctx_tuples=dispatched_ctx, issuer=issuer,
)
return responses
return None
def _cmd_help(
@@ -120,7 +120,11 @@ async def telegram_webhook(
async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text),
):
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
responses = await handle_command(
bot, chat_id, text,
language_code=effective_lang,
issuer=from_user or None,
)
if not responses:
_LOGGER.info(
"Command produced no response (cmd=%r) after %.0f ms",
@@ -0,0 +1,89 @@
"""Generic webhook provider bot command handler.
The generic webhook provider has no upstream API to query its only
runtime signal is the stream of incoming webhook payloads recorded as
``EventLog`` rows. ``/status`` therefore reports DB-derived stats:
* ``trackers_active`` enabled ``NotificationTracker`` rows for the provider
* ``trackers_total`` all ``NotificationTracker`` rows for the provider
* ``provider_name`` the provider's display name
* ``last_event`` formatted timestamp of the most recent received event,
or ``-`` if nothing has been received yet
"""
from __future__ import annotations
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
NotificationTracker,
ServiceProvider,
TelegramBot,
)
from .base import CommandResponse, ProviderCommandHandler
from .command_utils import get_last_event_str
from .handler import _render_cmd_template
_WEBHOOK_COMMANDS = {"status"}
async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
"""Build the context for ``/status`` on a webhook provider."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider.id
)
)
trackers = list(result.all())
active = [t for t in trackers if t.enabled]
last_str = await get_last_event_str([t.id for t in active])
return {
"trackers_active": len(active),
"trackers_total": len(trackers),
"provider_name": provider.name or "",
"last_event": last_str,
}
class WebhookCommandHandler(ProviderCommandHandler):
"""Handles ``/status`` for generic-webhook providers."""
provider_type = "webhook"
def get_provider_commands(self) -> set[str]:
return _WEBHOOK_COMMANDS
async def handle(
self,
cmd: str,
args: str,
count: int,
locale: str,
response_mode: str,
provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — webhook has no album scope
page: int = 1,
) -> CommandResponse | None:
if cmd != "status":
return None
ctx = await _cmd_status(provider)
return CommandResponse(
text=_render_cmd_template(cmd_templates, "status", locale, ctx),
)
@@ -90,6 +90,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
@@ -105,6 +109,8 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
("ix_event_log_command_tracker_id", "command_tracker_id"),
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
@@ -1363,6 +1369,12 @@ _INDEXES: list[tuple[str, str, str]] = [
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
("ix_action_rule_action_id", "action_rule", "action_id"),
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
# ORDER BY fire_at. The composite (status, fire_at) is the only access
# pattern; an individual fire_at index isn't needed.
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
]
@@ -1391,6 +1403,95 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
Early builds of this feature created the table with a default ``NO ACTION``
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
retention horizon with SQLite's enforced foreign_keys PRAGMA, a pending
DeferredDispatch row pointing at an aging-out event_log row would block
the cleanup with an FK violation.
SQLite can't ALTER a constraint without rebuilding the table. The table
has zero rows in any prod install old enough to need this fix (the
feature shipped in the same release as this migration), so a drop +
recreate via ``create_all`` is safe.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type='table' AND name='deferred_dispatch'"
)
).fetchone()
)
ddl = (row[0] or "") if row else ""
if "ON DELETE SET NULL" in ddl.upper():
return
# Confirm there's nothing to migrate — refuse to drop a populated
# table even though the schema was wrong. Better to leave a warning
# than to lose state.
count_row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text("SELECT COUNT(*) FROM deferred_dispatch")
).fetchone()
)
if count_row and count_row[0]:
logger.warning(
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
"table holds %d rows; not auto-dropping. Inspect manually.",
count_row[0],
)
return
await conn.execute(text("DROP TABLE deferred_dispatch"))
logger.info(
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
"with ON DELETE SET NULL on event_log_id",
)
# Recreate the table from the SQLModel metadata in this same txn.
from sqlmodel import SQLModel
# Ensure the model is registered on metadata before we ask create_all
# to build it. Lazy import to avoid a circular at module load time.
from .models import DeferredDispatch # noqa: F401
await conn.run_sync(
SQLModel.metadata.create_all,
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
)
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
"""Add a partial unique index preventing duplicate pending defers.
Without this, two webhook handlers (or a webhook racing the watcher)
can both call ``_find_pending_asset_rows`` and find nothing, then both
INSERT defeating coalescing. The partial index makes the second
INSERT raise ``IntegrityError`` and the caller's transaction abort,
after which a retry will see the now-visible row.
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
Once the table exists this is safe to run on every boot.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
try:
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"ux_deferred_dispatch_pending "
"ON deferred_dispatch(link_id, collection_id, event_type) "
"WHERE status = 'pending'"
))
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create partial unique index on deferred_dispatch",
exc_info=True,
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint, Text
from sqlalchemy import ForeignKey, UniqueConstraint, Text
from sqlmodel import JSON, Column, Field, SQLModel
@@ -494,6 +494,64 @@ class CommandTrackerListener(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class DeferredDispatch(SQLModel, table=True):
"""A dispatch held back by quiet hours, waiting for the window to end.
One row per ``(link, event_type, collection_id)`` for asset events newly
arriving events for the same key coalesce into the existing row's
``event_payload`` (union of added/removed asset sets) instead of inserting
a duplicate row. Non-asset events (push, pr_opened, ups_*, ) get a fresh
row each time because they aren't logically cancellable.
At drain time the scheduler picks up rows where ``status='pending'`` and
``fire_at <= now``, re-resolves the link/target/config against current
state (so subsequent config edits apply), and dispatches.
"""
__tablename__ = "deferred_dispatch"
id: int | None = Field(default=None, primary_key=True)
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
tracker_id: int = Field(foreign_key="notification_tracker.id", index=True)
# The specific link this deferral targets. On drain we re-fetch by ID; if
# the link was disabled or removed in the meantime we drop with a
# ``deferred_then_dropped`` log row instead of dispatching to nothing.
link_id: int = Field(
foreign_key="notification_tracker_target.id", index=True,
)
# The event_log row written when the event was first detected. The drain
# writes a follow-up event_log row referencing this id so the dashboard
# can show "delivered at HH:MM, originally detected at HH:MM".
#
# ``ondelete="SET NULL"`` matters because the daily ``_cleanup_old_events``
# job hard-deletes event_log rows past the retention horizon. Without
# SET NULL, an old pending DeferredDispatch row referencing an aging-out
# event_log row would either (a) prevent the delete with an FK violation
# under SQLite's enforced foreign_keys PRAGMA, or (b) leave a dangling
# reference on engines that don't enforce.
event_log_id: int | None = Field(
default=None,
sa_column=Column(
"event_log_id",
ForeignKey("event_log.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
event_type: str = Field(index=True)
collection_id: str = Field(default="", index=True)
# ``dataclasses.asdict(ServiceEvent)`` with datetime/enum normalisation —
# round-tripped via the helpers in ``services.deferred_dispatch``.
event_payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
fire_at: datetime = Field(index=True)
# ``pending`` until the drain runs; then ``fired``, ``dropped`` (link
# gone / event-type disabled after defer), or ``cancelled`` (coalesced
# away by a counter-event).
status: str = Field(default="pending", index=True)
fired_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
class EventLog(SQLModel, table=True):
"""Log of detected events."""
@@ -519,6 +577,17 @@ class EventLog(SQLModel, table=True):
default=None, foreign_key="action.id", index=True,
)
action_name: str = Field(default="")
# Bot command provenance. Populated when ``event_type`` starts with
# ``command_`` so the dashboard can render command activity alongside
# tracker and action events. NULL for non-command rows.
command_tracker_id: int | None = Field(
default=None, foreign_key="command_tracker.id", index=True,
)
command_tracker_name: str = Field(default="")
telegram_bot_id: int | None = Field(
default=None, foreign_key="telegram_bot.id", index=True,
)
bot_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="")
event_type: str = Field(index=True)
@@ -76,6 +76,8 @@ async def lifespan(app: FastAPI):
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_deferred_dispatch_event_log_fk,
migrate_deferred_dispatch_unique_pending,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -100,6 +102,11 @@ async def lifespan(app: FastAPI):
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
# FK-rebuild MUST run before the unique-index creation: drop+create_all
# of deferred_dispatch wipes its indexes; the next migration re-establishes
# the partial unique index.
await migrate_deferred_dispatch_event_log_fk(engine)
await migrate_deferred_dispatch_unique_pending(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -147,11 +154,8 @@ async def lifespan(app: FastAPI):
await dispose_engine()
try:
from importlib.metadata import version as _pkg_version
_APP_VERSION = _pkg_version("notify-bridge-server")
except Exception: # pragma: no cover — editable install edge cases
_APP_VERSION = "0.0.0+unknown"
from .version import resolve_version as _resolve_version
_APP_VERSION = _resolve_version()
app = FastAPI(title="Notify Bridge", version=_APP_VERSION, lifespan=lifespan)
@@ -0,0 +1,798 @@
"""Deferred-dispatch infrastructure for quiet-hours notifications.
When ``evaluate_event_gate`` returns ``QUIET_HOURS`` for a deferrable event
type, the dispatch site calls :func:`defer_event` instead of dropping. That
either inserts a new ``DeferredDispatch`` row or coalesces the event into an
existing pending row for the same ``(link_id, collection_id)`` asset add
+ matching remove cancels out, asset add + asset add merges set-union.
An APScheduler one-shot ``date`` job per quiet-window-end fires
:func:`drain_deferred_due` which:
1. Re-resolves each pending row's link/target/configs against current state.
2. Drops rows whose link/target was deleted or disabled in the meantime.
3. Re-checks quiet hours (in case the user extended the window mid-flight)
and pushes ``fire_at`` to the new end if still suppressed.
4. Dispatches via the existing ``NotificationDispatcher``.
5. Writes a follow-up ``event_log`` row referencing the original
``event_log_id`` so the dashboard shows "delivered late".
Wall-clock event types (``scheduled_message``) are explicitly NOT in
``_DEFERRABLE_EVENT_TYPES`` delivering a "good morning" memory at 3 pm is
worse than dropping it. Those keep the legacy drop-on-quiet-hours behavior.
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
from datetime import datetime, timezone
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.notifications.dispatcher import (
NotificationDispatcher,
TargetConfig,
)
from notify_bridge_core.providers.base import ServiceProviderType
from ..database.engine import get_engine
from ..database.models import (
DeferredDispatch,
EventLog,
NotificationTracker,
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Policy
# ---------------------------------------------------------------------------
# Change-driven event types that are safe to deliver after the quiet window
# ends — the underlying state change (a photo was added, a PR was opened, the
# UPS went on battery) remains relevant even hours later. Wall-clock event
# types (``scheduled_message``) are deliberately excluded: a "good morning"
# delivered at 3 pm is wrong, drop is more correct than late delivery.
_DEFERRABLE_EVENT_TYPES: frozenset[str] = frozenset({
# Immich
"assets_added", "assets_removed",
"collection_renamed", "collection_deleted", "sharing_changed",
# Gitea
"push",
"issue_opened", "issue_closed", "issue_commented",
"pr_opened", "pr_closed", "pr_merged", "pr_commented",
"release_published",
# Planka
"card_created", "card_updated", "card_moved", "card_deleted",
"card_commented", "comment_updated",
"board_created", "board_updated", "board_deleted",
"list_created", "list_updated", "list_deleted",
"attachment_created", "card_label_added", "task_completed",
# Generic webhook
"webhook_received",
# NUT (UPS)
"ups_online", "ups_on_battery", "ups_low_battery",
"ups_battery_restored", "ups_comms_lost", "ups_comms_restored",
"ups_replace_battery", "ups_overload",
})
# Per-tracker cap on the pending queue. A misconfigured short quiet window
# plus a chatty upstream (e.g. mass-imported album) could otherwise grow
# unbounded. On overflow we drop oldest (FIFO) — recent events still survive
# to be delivered, ancient ones are sacrificed.
_MAX_PENDING_PER_TRACKER = 1000
# Per-row timeout in the drain. Without this, a single hanging Telegram/SMTP
# call could stall the whole drain for hours and leave the rest of the queue
# stranded. Generous because legitimate large media uploads can take minutes.
_DRAIN_DISPATCH_TIMEOUT_SECONDS = 120
def is_deferrable(event_type: str) -> bool:
"""Whether this event type should be deferred (vs. dropped) during quiet hours."""
return event_type in _DEFERRABLE_EVENT_TYPES
# ---------------------------------------------------------------------------
# ServiceEvent (de)serialization
# ---------------------------------------------------------------------------
#
# JSON column stores ``dataclasses.asdict(event)`` plus a normalisation pass
# for datetimes (ISO strings) and enums (string values). Round-trip via the
# reverse pass below.
def _normalize_for_json(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (EventType, MediaType, ServiceProviderType)):
return value.value
if isinstance(value, dict):
return {k: _normalize_for_json(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_for_json(v) for v in value]
return value
def serialize_event(event: ServiceEvent) -> dict[str, Any]:
"""Convert a ``ServiceEvent`` to a JSON-safe dict for ``DeferredDispatch.event_payload``."""
return _normalize_for_json(dataclasses.asdict(event))
def _parse_dt(s: Any) -> datetime:
if isinstance(s, datetime):
return s
return datetime.fromisoformat(s)
def _deserialize_asset(data: dict[str, Any]) -> MediaAsset:
return MediaAsset(
id=data["id"],
type=MediaType(data["type"]),
filename=data["filename"],
created_at=_parse_dt(data["created_at"]),
owner_name=data.get("owner_name"),
description=data.get("description"),
tags=list(data.get("tags") or []),
thumbnail_url=data.get("thumbnail_url"),
preview_url=data.get("preview_url"),
full_url=data.get("full_url"),
extra=dict(data.get("extra") or {}),
)
def deserialize_event(data: dict[str, Any]) -> ServiceEvent:
"""Inverse of :func:`serialize_event`."""
return ServiceEvent(
event_type=EventType(data["event_type"]),
provider_type=ServiceProviderType(data["provider_type"]),
provider_name=data["provider_name"],
collection_id=data["collection_id"],
collection_name=data["collection_name"],
timestamp=_parse_dt(data["timestamp"]),
added_assets=[_deserialize_asset(a) for a in data.get("added_assets") or []],
removed_asset_ids=list(data.get("removed_asset_ids") or []),
added_count=int(data.get("added_count") or 0),
removed_count=int(data.get("removed_count") or 0),
old_name=data.get("old_name"),
new_name=data.get("new_name"),
old_shared=data.get("old_shared"),
new_shared=data.get("new_shared"),
extra=dict(data.get("extra") or {}),
)
# ---------------------------------------------------------------------------
# Coalescing
# ---------------------------------------------------------------------------
def _added_ids(payload: dict[str, Any]) -> list[str]:
return [a["id"] for a in payload.get("added_assets") or [] if "id" in a]
def _coalesce_assets_added(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Apply add-then-remove cancellation and add-then-add union.
Returns ``(action, updated_added_row, updated_removed_row)`` where action
is one of ``"insert"`` (caller must create a new row), ``"merge"`` (update
existing rows in place caller must session.add them).
"""
new_ids = [a.id for a in new_event.added_assets]
new_ids_set = set(new_ids)
# 1) If a matching assets_removed row pending: subtract — that's a re-add.
if existing_removed_row is not None:
removed_ids = list(existing_removed_row.event_payload.get("removed_asset_ids") or [])
kept = [rid for rid in removed_ids if rid not in new_ids_set]
if len(kept) != len(removed_ids):
payload = dict(existing_removed_row.event_payload)
payload["removed_asset_ids"] = kept
payload["removed_count"] = len(kept)
existing_removed_row.event_payload = payload
if not kept:
# All previously-removed IDs are being re-added → entire
# removal is cancelled. Mark for caller to delete.
existing_removed_row.status = "cancelled"
# The intersection re-adds are accounted for by the cancellation;
# remaining new IDs (those NOT in removed list) still need to land
# in the assets_added row.
new_ids = [nid for nid in new_ids if nid not in set(removed_ids)]
new_ids_set = set(new_ids)
if not new_ids:
# All new added IDs cancelled an existing remove → nothing to enqueue.
return ("merge", None, existing_removed_row)
if existing_added_row is None:
return ("insert", None, existing_removed_row)
# 2) Union with existing assets_added — earliest fire_at wins.
payload = dict(existing_added_row.event_payload)
existing_assets = list(payload.get("added_assets") or [])
seen = {a.get("id") for a in existing_assets}
new_serialized = serialize_event(new_event)
for a in new_serialized.get("added_assets") or []:
if a.get("id") in new_ids_set and a.get("id") not in seen:
existing_assets.append(a)
seen.add(a.get("id"))
payload["added_assets"] = existing_assets
payload["added_count"] = len(existing_assets)
existing_added_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
def _coalesce_assets_removed(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Mirror of :func:`_coalesce_assets_added` for removal events."""
new_ids = list(new_event.removed_asset_ids)
new_ids_set = set(new_ids)
# 1) If a matching assets_added row pending: subtract — that's an
# add-then-remove within the window, cancel both sides.
if existing_added_row is not None:
added = list(existing_added_row.event_payload.get("added_assets") or [])
kept_assets = [a for a in added if a.get("id") not in new_ids_set]
if len(kept_assets) != len(added):
payload = dict(existing_added_row.event_payload)
payload["added_assets"] = kept_assets
payload["added_count"] = len(kept_assets)
existing_added_row.event_payload = payload
if not kept_assets:
existing_added_row.status = "cancelled"
# IDs that were just added during the window don't need to flow
# into the assets_removed row — they're a wash.
cancelled_ids = {a.get("id") for a in added if a.get("id") in new_ids_set}
new_ids = [nid for nid in new_ids if nid not in cancelled_ids]
new_ids_set = set(new_ids)
if not new_ids:
return ("merge", existing_added_row, None)
if existing_removed_row is None:
return ("insert", existing_added_row, None)
# 2) Union with existing assets_removed — earliest fire_at wins.
payload = dict(existing_removed_row.event_payload)
existing_ids = list(payload.get("removed_asset_ids") or [])
seen = set(existing_ids)
for rid in new_ids:
if rid not in seen:
existing_ids.append(rid)
seen.add(rid)
payload["removed_asset_ids"] = existing_ids
payload["removed_count"] = len(existing_ids)
existing_removed_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
async def _find_pending_asset_rows(
session: AsyncSession,
link_id: int,
collection_id: str,
) -> tuple[DeferredDispatch | None, DeferredDispatch | None]:
"""Return ``(assets_added_row, assets_removed_row)`` pending for this link+collection."""
result = await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.link_id == link_id,
DeferredDispatch.collection_id == collection_id,
DeferredDispatch.status == "pending",
DeferredDispatch.event_type.in_(["assets_added", "assets_removed"]),
)
)
added_row: DeferredDispatch | None = None
removed_row: DeferredDispatch | None = None
for row in result.all():
if row.event_type == "assets_added":
added_row = row
elif row.event_type == "assets_removed":
removed_row = row
return added_row, removed_row
async def _trim_queue_if_needed(
session: AsyncSession,
tracker_id: int,
) -> None:
"""Drop oldest pending rows beyond the per-tracker cap with a log row each.
Loads the parent tracker so the emitted event_log rows carry proper
``tracker_name``/``provider_id``/``provider_name`` and slot into the
dashboard's "by tracker" grouping — without these the drop rows show up
under an unattributed bucket and confuse the audit trail.
"""
rows = (await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.tracker_id == tracker_id,
DeferredDispatch.status == "pending",
).order_by(DeferredDispatch.fire_at.asc(), DeferredDispatch.id.asc())
)).all()
overflow = len(rows) - _MAX_PENDING_PER_TRACKER
if overflow <= 0:
return
_LOGGER.warning(
"Deferred queue for tracker %d exceeds cap (%d > %d); dropping %d oldest",
tracker_id, len(rows), _MAX_PENDING_PER_TRACKER, overflow,
)
tracker = await session.get(NotificationTracker, tracker_id)
tracker_name = tracker.name if tracker else ""
provider_id = tracker.provider_id if tracker else None
provider_name = ""
if tracker is not None and provider_id is not None:
provider = await session.get(ServiceProvider, provider_id)
if provider is not None:
provider_name = provider.name
for row in rows[:overflow]:
await _mark_dropped(
session, row,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
reason="queue_overflow",
)
# ---------------------------------------------------------------------------
# Enqueue (called from dispatch sites when gate returns QUIET_HOURS)
# ---------------------------------------------------------------------------
async def defer_event(
session: AsyncSession,
*,
event: ServiceEvent,
user_id: int | None,
tracker_id: int,
link_id: int,
event_log_id: int | None,
fire_at: datetime,
) -> str:
"""Persist a deferred dispatch (or coalesce into an existing one).
Caller is responsible for committing the session. Returns one of:
* ``"inserted"`` a fresh DeferredDispatch row was created.
* ``"merged"`` coalesced into an existing row (union or partial cancel).
* ``"cancelled"`` the new event fully cancelled an existing pending one
(add-then-remove or remove-then-readd of the same asset IDs). Both sides
are gone after this call.
* ``"non_deferrable"`` event type is wall-clock; caller should drop it
with a ``"suppressed_quiet_hours_nondeferrable"`` event_log row.
"""
event_type = event.event_type.value
if not is_deferrable(event_type):
return "non_deferrable"
fire_at_utc = fire_at.astimezone(timezone.utc) if fire_at.tzinfo else fire_at.replace(tzinfo=timezone.utc)
# Asset events get set-merging across the same link+collection. Everything
# else just gets a new row — those events aren't naturally cancellable.
if event_type in ("assets_added", "assets_removed"):
added_row, removed_row = await _find_pending_asset_rows(
session, link_id, event.collection_id,
)
if event_type == "assets_added":
action, upd_added, upd_removed = _coalesce_assets_added(
event, added_row, removed_row,
)
else:
action, upd_added, upd_removed = _coalesce_assets_removed(
event, added_row, removed_row,
)
# Apply pending updates. ``status="cancelled"`` rows are deleted
# outright so the drain doesn't see them.
fully_cancelled = False
for row in (upd_added, upd_removed):
if row is None:
continue
if row.status == "cancelled":
await session.delete(row)
fully_cancelled = True
else:
session.add(row)
if action == "insert":
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# action == "merge" — either updated existing or fully cancelled.
return "cancelled" if fully_cancelled and (upd_added is None or upd_added.status == "cancelled") and (upd_removed is None or upd_removed.status == "cancelled") else "merged"
# Non-asset event: no coalescing, fresh row.
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# ---------------------------------------------------------------------------
# Drain (called by APScheduler date job at quiet_hours_end_at)
# ---------------------------------------------------------------------------
async def drain_deferred_due(now: datetime | None = None) -> dict[str, int]:
"""Dispatch all pending DeferredDispatch rows whose ``fire_at <= now``.
Re-resolves link/target/configs against current DB state so config edits
between suppression and drain time take effect. Returns a small stats
dict for logging.
Implementation note: rows are *re-fetched* by id inside each per-tracker
session rather than carried across session boundaries. Carrying a row
instance to a new session and calling ``session.add(row)`` on a detached
PK-bearing instance triggers an INSERT (collision with the existing PK)
on flush a class of bug that's invisible until the first session
closes, hence the up-front re-fetch.
"""
now_utc = (now or datetime.now(timezone.utc))
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=timezone.utc)
stats = {"fired": 0, "dropped": 0, "rescheduled": 0, "errors": 0}
engine = get_engine()
async with AsyncSession(engine) as session:
# Only pull the row identity + grouping key. Loading the full ORM
# objects in a session that's about to close just wastes work — we
# re-fetch fresh attached instances in the per-tracker session below.
ident_rows = (await session.exec(
select(DeferredDispatch.id, DeferredDispatch.tracker_id).where(
DeferredDispatch.status == "pending",
DeferredDispatch.fire_at <= now_utc,
).order_by(DeferredDispatch.fire_at.asc())
)).all()
if not ident_rows:
_LOGGER.debug("drain_deferred_due: no pending rows due")
return stats
_LOGGER.info(
"Draining %d deferred dispatches due at %s",
len(ident_rows), now_utc.isoformat(),
)
# Group by tracker so a single per-tracker session can re-fetch its rows
# (attached) and re-resolve link state once.
ids_by_tracker: dict[int, list[int]] = {}
for row_id, tracker_id in ident_rows:
if row_id is None:
continue
ids_by_tracker.setdefault(tracker_id, []).append(row_id)
from .watcher import _get_telegram_caches
from .http_session import get_http_session
url_cache, asset_cache = await _get_telegram_caches()
shared_session = await get_http_session()
dispatcher = NotificationDispatcher(
url_cache=url_cache, asset_cache=asset_cache, session=shared_session,
)
for tracker_id, row_ids in ids_by_tracker.items():
async with AsyncSession(engine) as session:
tracker = await session.get(NotificationTracker, tracker_id)
# Re-fetch rows freshly attached to THIS session.
rows = (await session.exec(
select(DeferredDispatch).where(DeferredDispatch.id.in_(row_ids))
)).all()
if tracker is None or not tracker.enabled:
# Tracker deleted or disabled between defer and drain — drop
# all pending rows for it. Disable matches the live-path
# invariant (watcher / webhooks / scheduled_dispatch all
# short-circuit when ``tracker.enabled`` is False).
reason = "tracker_removed" if tracker is None else "tracker_disabled_after_defer"
for row in rows:
await _mark_dropped(
session, row,
tracker=tracker, reason=reason,
)
stats["dropped"] += 1
await session.commit()
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
provider_config = dict(provider.config) if provider else {}
provider_id = provider.id if provider else tracker.provider_id
provider_name = provider.name if provider else ""
app_tz = await get_app_timezone(session)
# Reload current link state. Broadcast links emit ONE entry per
# child target sharing the SAME parent ``link_id`` — a plain
# ``{link_id: ld}`` dict would silently drop N-1 children. The
# drain dispatches to every expanded entry for the parent.
link_data = await load_link_data(session, tracker_id)
link_by_id: dict[int, list[dict[str, Any]]] = {}
for ld in link_data:
key = ld.get("link_id")
if key is None:
continue
link_by_id.setdefault(key, []).append(ld)
for row in rows:
try:
await _process_row(
session, row, tracker, provider_id, provider_name,
provider_config, app_tz, link_by_id, dispatcher, stats,
)
except Exception as err: # noqa: BLE001 — keep draining other rows
_LOGGER.exception(
"Drain failed for deferred dispatch id=%s: %s", row.id, err,
)
stats["errors"] += 1
await session.commit()
_LOGGER.info("Drain complete: %s", stats)
return stats
async def _mark_dropped(
session: AsyncSession,
row: DeferredDispatch,
*,
tracker: NotificationTracker | None = None,
tracker_name: str = "",
provider_id: int | None = None,
provider_name: str = "",
reason: str,
) -> None:
"""Record a drop on the deferred row and emit a follow-up event_log entry.
``tracker``/``tracker_name``/``provider_id``/``provider_name`` populate
the new event_log row's owner/provider columns so the dashboard "by
tracker" grouping works for the drop path. Without these the row would
have empty strings and slot into the "unknown" bucket.
"""
if tracker is not None:
tracker_name = tracker_name or tracker.name
if provider_id is None:
provider_id = tracker.provider_id
payload = row.event_payload if isinstance(row.event_payload, dict) else {}
row.status = "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=payload.get("collection_name", ""),
assets_count=int(payload.get("added_count", 0))
or int(payload.get("removed_count", 0)),
details={
"dispatch_status": "deferred_then_dropped",
"reason": reason,
"original_event_log_id": row.event_log_id,
"provider_type": payload.get("provider_type", ""),
},
))
async def _process_row(
session: AsyncSession,
row: DeferredDispatch,
tracker: NotificationTracker,
provider_id: int,
provider_name: str,
provider_config: dict[str, Any],
app_tz: str,
link_by_id: dict[int, list[dict[str, Any]]],
dispatcher: NotificationDispatcher,
stats: dict[str, int],
) -> None:
"""Drain a single row: re-resolve link, re-evaluate gate, dispatch.
``link_by_id`` maps parent link_id list of expanded entries (one per
broadcast child, or a single-element list for regular targets). Every
entry produces its own target_config so a broadcast deferred row fans
out to all current children at drain time.
"""
expanded = link_by_id.get(row.link_id)
if not expanded:
# Link removed/disabled between defer and drain.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="link_removed",
)
stats["dropped"] += 1
return
# Every expanded entry for a parent link shares the same tracking_config,
# so the gate decision and ``apply_tracking_display_filters`` shaping are
# made once. Only the target_configs differ across children.
tc = expanded[0].get("tracking_config")
event = deserialize_event(row.event_payload)
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="event_type_disabled_after_defer",
)
stats["dropped"] += 1
return
if outcome.reason is GateReason.QUIET_HOURS and outcome.quiet_hours_end_at is not None:
row.fire_at = outcome.quiet_hours_end_at
session.add(row)
stats["rescheduled"] += 1
try:
from .scheduler import schedule_deferred_drain
schedule_deferred_drain(outcome.quiet_hours_end_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to reschedule drain for %s", outcome.quiet_hours_end_at,
)
return
shaped = apply_tracking_display_filters(event, tc)
if shaped is None:
# ``notify_favorites_only`` (or another display filter) dropped every
# asset from the event. Inconsistent earlier behavior swallowed this
# silently; we now route through the same "dropped + event_log"
# pathway as link_removed so the dashboard shows why.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="filtered_after_defer",
)
stats["dropped"] += 1
return
# Build one target_config per expanded child (regular targets → length 1;
# broadcast → length N children).
target_configs: list[TargetConfig] = []
for ld in expanded:
tmpl = ld.get("template_config")
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld.get("template_slots"),
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=(tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y"),
provider_api_key=provider_config.get("api_key") or provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", "") or provider_config.get("url", ""),
receivers=ld["receivers"],
))
# Per-row timeout — a single hanging remote call (Telegram outage, slow
# SMTP) must not stall the rest of the queue.
try:
results = await asyncio.wait_for(
dispatcher.dispatch(shaped, target_configs),
timeout=_DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
_LOGGER.warning(
"Drain dispatch for row %s timed out after %ds",
row.id, _DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
results = [{"success": False, "error": f"timeout after {_DRAIN_DISPATCH_TIMEOUT_SECONDS}s"}]
success = any(r.get("success") for r in results)
row.status = "fired" if success else "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
if success:
stats["fired"] += 1
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "delivered_after_quiet_hours",
"original_event_log_id": row.event_log_id,
"deferred_for_seconds": int(
(row.fired_at - row.created_at).total_seconds()
),
"provider_type": event.provider_type.value,
},
))
else:
stats["dropped"] += 1
first_err = next((r.get("error") for r in results if not r.get("success")), "unknown")
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "deferred_then_failed",
"reason": str(first_err)[:200],
"original_event_log_id": row.event_log_id,
"provider_type": event.provider_type.value,
},
))
# ---------------------------------------------------------------------------
# Startup: reschedule pending drain jobs found in the DB
# ---------------------------------------------------------------------------
async def load_pending_drain_jobs() -> int:
"""At startup, scan ``DeferredDispatch`` for pending rows and (re)schedule drains.
Rows whose ``fire_at`` already passed get a single immediate-fire job; the
rest get one job per distinct ``fire_at`` (minute-rounded) so all rows
sharing a window end share a drain.
"""
from .scheduler import schedule_deferred_drain
engine = get_engine()
async with AsyncSession(engine) as session:
rows = (await session.exec(
select(DeferredDispatch.fire_at).where(
DeferredDispatch.status == "pending",
)
)).all()
if not rows:
return 0
unique_fire_ats: set[datetime] = set()
for fa in rows:
if isinstance(fa, datetime):
unique_fire_ats.add(fa.astimezone(timezone.utc) if fa.tzinfo else fa.replace(tzinfo=timezone.utc))
for fa in unique_fire_ats:
schedule_deferred_drain(fa)
_LOGGER.info(
"Loaded %d pending deferred dispatches; scheduled %d drain job(s)",
len(rows), len(unique_fire_ats),
)
return len(unique_fire_ats)
@@ -5,7 +5,9 @@ from __future__ import annotations
import dataclasses
import logging
import random
from datetime import datetime, time, timezone
from dataclasses import dataclass
from datetime import datetime, time, timedelta, timezone
from enum import Enum
from typing import Any, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -33,6 +35,35 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__)
class GateReason(str, Enum):
"""Why ``evaluate_event_gate`` allowed or blocked a dispatch.
String-backed so it can be persisted in ``EventLog.details`` JSON and
round-trip cleanly.
"""
ALLOWED = "allowed"
EVENT_TYPE_DISABLED = "event_type_disabled"
QUIET_HOURS = "quiet_hours"
@dataclass(frozen=True)
class GateOutcome:
"""Result of evaluating a (event, tracking_config) pair against dispatch gates.
``quiet_hours_end_at`` is set iff ``reason == QUIET_HOURS`` and gives the
UTC datetime at which the current quiet window ends used by the
deferred-dispatch scheduler to know when to fire the held notification.
"""
reason: GateReason
quiet_hours_end_at: datetime | None = None
@property
def allowed(self) -> bool:
return self.reason is GateReason.ALLOWED
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
@@ -44,6 +75,59 @@ def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
return ZoneInfo("UTC")
def quiet_hours_status(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> datetime | None:
"""Return the UTC datetime when the current quiet window ends, or None.
Returns ``None`` when:
* either bound is missing,
* the bounds are malformed,
* the current local time is outside the configured window.
Returns a UTC ``datetime`` aligned to ``HH:MM`` (seconds=0, microseconds=0)
representing the next end-of-window moment after "now" when the current
time IS inside the window. For overnight windows (e.g. 22:00-06:00) the
end may be tomorrow.
"""
if not start or not end:
return None
try:
tz = _resolve_zoneinfo(tz_name)
now_local = datetime.now(timezone.utc).astimezone(tz)
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
except (ValueError, TypeError):
return None
# ``start == end`` (e.g. "00:00-00:00") has no consistent meaning: under
# the normal-window branch the window is one instant wide; under the
# overnight-window branch it's effectively always-on. Either is almost
# certainly a user mistake, so treat it as "no window configured" rather
# than silently deferring every notification all day.
if t_start == t_end:
return None
now_t = now_local.time()
if t_start <= t_end:
in_window = t_start <= now_t <= t_end
else:
in_window = now_t >= t_start or now_t <= t_end
if not in_window:
return None
end_today = now_local.replace(
hour=t_end.hour, minute=t_end.minute, second=0, microsecond=0,
)
# If today's end already passed (overnight window, post-midnight half),
# the actual end is tomorrow at the same wall-clock time.
if end_today <= now_local:
end_today = end_today + timedelta(days=1)
return end_today.astimezone(timezone.utc)
def in_quiet_hours(
start: str | None,
end: str | None,
@@ -51,23 +135,12 @@ def in_quiet_hours(
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
Thin wrapper over ``quiet_hours_status`` preserved for back-compat with
callers that only need the boolean. New code should prefer
``quiet_hours_status`` (or ``evaluate_event_gate``) when the window end
time matters.
"""
if not start or not end:
return False
try:
tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
return t_start <= now <= t_end
else:
# Overnight window (e.g., 22:00 - 06:00)
return now >= t_start or now <= t_end
except (ValueError, TypeError):
return False
return quiet_hours_status(start, end, tz_name) is not None
async def get_app_timezone(session: AsyncSession) -> str:
@@ -77,18 +150,13 @@ async def get_app_timezone(session: AsyncSession) -> str:
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
def _event_type_enabled(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Return True iff the tracking config's per-event-type flag allows this event.
Quiet hours are NOT considered here this is the user's "do I care about
this kind of event at all" gate. See ``evaluate_event_gate`` for the
combined gate that also folds in quiet hours.
"""
event_type = event.event_type.value
flag_map = {
# Immich events
@@ -140,6 +208,52 @@ def event_allowed_by_config(
return flag_map.get(event_type, True)
def evaluate_event_gate(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> GateOutcome:
"""Decide whether an event should dispatch through the given tracking config.
Returns a :class:`GateOutcome` carrying both the verdict and when blocked
by quiet hours the UTC datetime at which the window ends so the caller
can schedule a deferred dispatch.
Order of checks: quiet hours first, then per-event-type flag. Quiet hours
is the "louder" gate (it applies to every type), so reporting it first
avoids the surprising case of "you disabled this event type" showing up
when the user really just opened the quiet window.
"""
if tc.quiet_hours_enabled:
end_at = quiet_hours_status(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name,
)
if end_at is not None:
return GateOutcome(
reason=GateReason.QUIET_HOURS,
quiet_hours_end_at=end_at,
)
if not _event_type_enabled(event, tc):
return GateOutcome(reason=GateReason.EVENT_TYPE_DISABLED)
return GateOutcome(reason=GateReason.ALLOWED)
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Boolean back-compat wrapper around :func:`evaluate_event_gate`.
New call sites should use ``evaluate_event_gate`` directly so they can
distinguish a quiet-hours suppression (deferrable) from an event-type
disable (drop forever).
"""
return evaluate_event_gate(event, tc, tz_name).allowed
# --- Display-time filters driven by TrackingConfig -------------------------
#
# These transform a ServiceEvent so the dispatched notification reflects the
@@ -472,6 +586,7 @@ async def load_link_data(
resolved = await _resolve_target(session, child_target)
link_data.append({
**resolved,
"link_id": tt.id,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
@@ -482,6 +597,7 @@ async def load_link_data(
resolved = await _resolve_target(session, target)
link_data.append({
**resolved,
"link_id": tt.id,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
@@ -0,0 +1,295 @@
"""Upstream release-check service.
Reads the configured release provider, asks it for the latest upstream release,
and caches the result into :class:`AppSetting` rows so the API can serve the
status without re-hitting the network. All failures are swallowed and surfaced
through ``release_error`` the server must stay up even if Gitea is down.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import aiohttp
from notify_bridge_core.release import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
build_release_provider,
)
from notify_bridge_core.release.base import is_newer
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
from ..database.models import AppSetting
_LOGGER = logging.getLogger(__name__)
# Cached-state AppSetting keys (read by the API, written by the checker).
KEY_LATEST_TAG = "release_latest_tag"
KEY_LATEST_VERSION = "release_latest_version"
KEY_LATEST_URL = "release_latest_url"
KEY_LATEST_BODY = "release_latest_body"
KEY_LATEST_NAME = "release_latest_name"
KEY_LATEST_PUBLISHED_AT = "release_latest_published_at"
KEY_LATEST_PRERELEASE = "release_latest_prerelease"
KEY_CHECKED_AT = "release_checked_at"
KEY_ERROR = "release_error"
# Operator-configured keys.
KEY_PROVIDER_KIND = "release_provider_kind"
KEY_PROVIDER_URL = "release_provider_url"
KEY_PROVIDER_REPO = "release_provider_repo"
KEY_INCLUDE_PRERELEASES = "release_include_prereleases"
KEY_CHECK_INTERVAL_HOURS = "release_check_interval_hours"
# Allowed range for the interval (matches the UI hint).
INTERVAL_MIN_HOURS = 1
INTERVAL_MAX_HOURS = 168
# Minimum gap between checks. Independent of the configured interval — a flood
# of /release/check API calls or scheduler misfires can't push real load on
# upstream Gitea within this window.
_MIN_CHECK_INTERVAL = timedelta(seconds=30)
# Serialises concurrent run_check invocations (scheduled job + manual force
# check + provider-changed save can all fire close together).
_run_lock = asyncio.Lock()
_CACHED_KEYS = (
KEY_LATEST_TAG,
KEY_LATEST_VERSION,
KEY_LATEST_URL,
KEY_LATEST_BODY,
KEY_LATEST_NAME,
KEY_LATEST_PUBLISHED_AT,
KEY_LATEST_PRERELEASE,
KEY_CHECKED_AT,
KEY_ERROR,
)
@dataclass(frozen=True)
class ReleaseStatus:
"""Snapshot returned by :func:`load_status` and friends."""
provider: str
current: str
latest: str | None
latest_tag: str | None
latest_url: str | None
latest_body: str | None
latest_name: str | None
latest_published_at: str | None
latest_prerelease: bool
checked_at: str | None
update_available: bool
error: str | None
def _server_version() -> str:
"""Resolve the running server version (delegates to the shared helper).
Routed through :mod:`notify_bridge_server.version` so the "current" the
UI reports matches `/api/health` and is robust to stale editable installs.
"""
from ..version import resolve_version
return resolve_version()
def parse_interval_hours(raw: str | None, default: int = 12) -> int:
"""Clamp/parse the interval setting into a sensible integer."""
try:
value = int((raw or "").strip() or default)
except (TypeError, ValueError):
return default
return max(INTERVAL_MIN_HOURS, min(INTERVAL_MAX_HOURS, value))
def _coerce_provider_kind(raw: str | None) -> str:
"""Normalise the stored kind to a known enum value (default: disabled)."""
try:
return ReleaseProviderKind(raw or "").value
except ValueError:
return ReleaseProviderKind.DISABLED.value
async def load_status() -> ReleaseStatus:
"""Read the latest cached status without performing a network call."""
async with AsyncSession(get_engine()) as session:
provider = await get_setting(session, KEY_PROVIDER_KIND)
latest_tag = await get_setting(session, KEY_LATEST_TAG)
latest_version = await get_setting(session, KEY_LATEST_VERSION)
latest_url = await get_setting(session, KEY_LATEST_URL)
latest_body = await get_setting(session, KEY_LATEST_BODY)
latest_name = await get_setting(session, KEY_LATEST_NAME)
latest_published_at = await get_setting(session, KEY_LATEST_PUBLISHED_AT)
latest_prerelease = await get_setting(session, KEY_LATEST_PRERELEASE)
checked_at = await get_setting(session, KEY_CHECKED_AT)
error = await get_setting(session, KEY_ERROR)
current = _server_version()
has_latest = bool(latest_version)
update_available = bool(has_latest and is_newer(latest_version, current))
return ReleaseStatus(
provider=_coerce_provider_kind(provider),
current=current,
latest=latest_version or None,
latest_tag=latest_tag or None,
latest_url=latest_url or None,
latest_body=latest_body or None,
latest_name=latest_name or None,
latest_published_at=latest_published_at or None,
latest_prerelease=latest_prerelease == "1",
checked_at=checked_at or None,
update_available=update_available,
error=error or None,
)
async def run_check(*, force: bool = False) -> ReleaseStatus:
"""Hit the configured provider and persist the result, then return it.
Args:
force: bypass the per-process rate limit. Used by the manual
"Check now" admin action; the scheduled probe never forces.
"""
async with _run_lock:
return await _run_check_locked(force=force)
async def _run_check_locked(*, force: bool) -> ReleaseStatus:
from .http_session import get_http_session
# Throttle: if the last check landed within _MIN_CHECK_INTERVAL and the
# caller didn't ask for force, skip the network round-trip and return the
# cached status. Force is still gated by the lock above, so an abusive
# admin spamming /release/check serialises to one in-flight at a time.
if not force:
async with AsyncSession(get_engine()) as session:
last = await get_setting(session, KEY_CHECKED_AT)
if last:
try:
last_dt = datetime.fromisoformat(last)
if datetime.now(timezone.utc) - last_dt < _MIN_CHECK_INTERVAL:
return await load_status()
except ValueError:
pass # corrupted timestamp → fall through and overwrite
async with AsyncSession(get_engine()) as session:
provider_kind = await get_setting(session, KEY_PROVIDER_KIND)
provider_url = await get_setting(session, KEY_PROVIDER_URL)
provider_repo = await get_setting(session, KEY_PROVIDER_REPO)
include_prereleases = (await get_setting(session, KEY_INCLUDE_PRERELEASES)) == "1"
http = await get_http_session()
provider = build_release_provider(
provider_kind or ReleaseProviderKind.DISABLED.value,
session=http,
url=provider_url,
repo=provider_repo,
)
timestamp = datetime.now(timezone.utc).isoformat()
if provider is None:
# Disabled (no error to surface) vs misconfigured (operator action
# required) are different states — the UI distinguishes them.
kind = _coerce_provider_kind(provider_kind)
err = (
ReleaseErrorCode.DISABLED.value
if kind == ReleaseProviderKind.DISABLED.value
else ReleaseErrorCode.MISCONFIGURED.value
)
await persist_release_state(checked_at=timestamp, error=err, info=None)
return await load_status()
try:
info = await provider.fetch_latest(include_prereleases=include_prereleases)
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Release provider network error: %s", err)
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.NETWORK_ERROR.value,
info=None,
)
return await load_status()
except ValueError as err:
_LOGGER.warning("Release provider parse/validation error: %s", err)
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.PARSE_ERROR.value,
info=None,
)
return await load_status()
if info is None:
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.NO_RELEASE_FOUND.value,
info=None,
)
return await load_status()
await persist_release_state(checked_at=timestamp, error=None, info=info)
return await load_status()
async def persist_release_state(
*,
checked_at: str,
error: str | None,
info: ReleaseInfo | None,
) -> None:
"""Write all cached-state keys in one transaction.
Public because the settings PUT handler invokes it to flush stale cache
when the operator points the provider at a different repo we don't want
the previous repo's "latest" to keep advertising as available.
"""
if info is None:
rows: dict[str, str] = {
KEY_LATEST_TAG: "",
KEY_LATEST_VERSION: "",
KEY_LATEST_URL: "",
KEY_LATEST_BODY: "",
KEY_LATEST_NAME: "",
KEY_LATEST_PUBLISHED_AT: "",
KEY_LATEST_PRERELEASE: "0",
}
else:
rows = {
KEY_LATEST_TAG: info.tag,
KEY_LATEST_VERSION: info.version,
KEY_LATEST_URL: info.url or "",
KEY_LATEST_BODY: info.body or "",
KEY_LATEST_NAME: info.name or "",
KEY_LATEST_PUBLISHED_AT: info.published_at or "",
KEY_LATEST_PRERELEASE: "1" if info.prerelease else "0",
}
rows[KEY_CHECKED_AT] = checked_at
rows[KEY_ERROR] = error or ""
async with AsyncSession(get_engine()) as session:
for key, value in rows.items():
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
await session.commit()
def cached_keys() -> tuple[str, ...]:
"""Return the keys the checker writes — used by API masking helpers."""
return _CACHED_KEYS
@@ -42,8 +42,9 @@ from ..database.models import (
TrackingConfig,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -262,7 +263,11 @@ async def dispatch_scheduled_for_tracker(
if tc is not None:
if not getattr(tc, f"{kind}_enabled", True):
continue
if not event_allowed_by_config(event, tc, app_tz):
# Scheduled / periodic / memory dispatches are wall-clock
# by nature — a "good morning" delivered at 3 pm is wrong,
# so quiet hours = drop (not defer) for these kinds. The
# other gate (per-event-type flag) still applies.
if not evaluate_event_gate(event, tc, app_tz).allowed:
continue
if tmpl is None:
continue
@@ -153,6 +153,16 @@ async def start_scheduler() -> None:
# Load scheduled backup job if enabled
await _load_backup_job()
# Re-arm any deferred-dispatch drains that were pending across restart.
from .deferred_dispatch import load_pending_drain_jobs
await load_pending_drain_jobs()
# And install the periodic safety-net catch-up scan.
_schedule_drain_catchup()
# Schedule the upstream release-check probe.
await _schedule_release_check()
def _schedule_event_cleanup() -> None:
"""Schedule a daily job to delete EventLog entries older than 90 days."""
@@ -1079,6 +1089,129 @@ async def unschedule_backup() -> None:
_LOGGER.info("Unscheduled backup job")
# ---------------------------------------------------------------------------
# Deferred-dispatch drain
# ---------------------------------------------------------------------------
#
# When ``defer_event`` enqueues a quiet-hours notification, the calling site
# asks us to add a one-shot ``date`` job at ``quiet_hours_end_at``. We key the
# job id by the minute-rounded end time so multiple defers that share the same
# window-end share a single drain job (idempotent via ``replace_existing``).
#
# At fire time the job runs ``drain_deferred_due`` which scans all pending
# rows and dispatches whatever is ready.
#
# A periodic catch-up scan runs every ``_DRAIN_CATCHUP_INTERVAL_SECONDS`` as
# the safety net for failure modes the one-shot job can't cover:
# * APScheduler's misfire grace exceeded (event loop blocked past fire_at;
# the date job is silently discarded by the scheduler)
# * Process killed between the deferred-row DB commit and the
# ``schedule_deferred_drain`` call — row exists, job doesn't
# * Clock drift / DST seam edge cases
_DEFERRED_DRAIN_PREFIX = "deferred_drain_"
_DEFERRED_DRAIN_CATCHUP_JOB = "deferred_drain_catchup"
# Generous so a temporarily-blocked event loop doesn't make the scheduler
# discard our drain job. Once discarded the deferred rows would wait for the
# next process restart or the catch-up scan below — survivable but visibly
# late from the user's perspective.
_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS = 3600
# 5 min trade-off between "promptness of late delivery" and "extra DB churn".
# The scan is a single indexed lookup on (status, fire_at).
_DRAIN_CATCHUP_INTERVAL_SECONDS = 300
def _drain_job_id_for(fire_at_utc: datetime) -> str:
return f"{_DEFERRED_DRAIN_PREFIX}{fire_at_utc.strftime('%Y%m%d%H%M')}"
def schedule_deferred_drain(fire_at_utc: datetime) -> None:
"""Add an idempotent one-shot drain job for ``fire_at_utc``.
Past times schedule a near-immediate firing (now+1s) the drain query
handles ``fire_at <= now`` regardless of which job fired, so a near-miss
still picks up the work.
"""
from datetime import datetime, timezone
if fire_at_utc.tzinfo is None:
fire_at_utc = fire_at_utc.replace(tzinfo=timezone.utc)
scheduler = get_scheduler()
job_id = _drain_job_id_for(fire_at_utc)
run_at = fire_at_utc
if run_at <= datetime.now(timezone.utc):
from datetime import timedelta
run_at = datetime.now(timezone.utc) + timedelta(seconds=1)
scheduler.add_job(
_run_deferred_drain,
"date",
run_date=run_at,
id=job_id,
args=[fire_at_utc.isoformat()],
replace_existing=True,
max_instances=1,
# Override the global 5-min grace — see module-level comment.
misfire_grace_time=_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS,
)
_LOGGER.debug("Scheduled deferred drain %s (fire_at=%s)", job_id, fire_at_utc.isoformat())
def _schedule_drain_catchup() -> None:
"""Install the periodic catch-up scan. See module comment."""
from apscheduler.triggers.interval import IntervalTrigger
scheduler = get_scheduler()
if scheduler.get_job(_DEFERRED_DRAIN_CATCHUP_JOB):
return
scheduler.add_job(
_run_deferred_drain_catchup,
IntervalTrigger(seconds=_DRAIN_CATCHUP_INTERVAL_SECONDS),
id=_DEFERRED_DRAIN_CATCHUP_JOB,
replace_existing=True,
max_instances=1,
coalesce=True,
)
_LOGGER.info(
"Scheduled deferred-dispatch catch-up scan every %ds",
_DRAIN_CATCHUP_INTERVAL_SECONDS,
)
async def _run_deferred_drain(fire_at_iso: str) -> None:
"""APScheduler entry point — log the original fire_at then drain due rows.
The ``fire_at_iso`` arg is only used for logging; the drain itself picks
up every pending row whose ``fire_at`` has passed.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
_LOGGER.info("Deferred drain (fire_at=%s) stats: %s", fire_at_iso, stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred drain (fire_at=%s) failed: %s", fire_at_iso, err)
async def _run_deferred_drain_catchup() -> None:
"""Periodic safety-net drain — see module comment.
Distinct from the per-fire-at job only in cadence and log line; calls the
same ``drain_deferred_due`` which is a no-op when nothing is due.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
# Quiet at debug level when nothing happened — every 5 min is too
# noisy at info on an idle system.
if stats.get("fired") or stats.get("dropped") or stats.get("errors"):
_LOGGER.info("Deferred catch-up stats: %s", stats)
else:
_LOGGER.debug("Deferred catch-up stats: %s", stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred catch-up drain failed: %s", err)
async def _run_scheduled_backup() -> None:
"""Run a scheduled backup (called by APScheduler)."""
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
@@ -1116,3 +1249,66 @@ async def _run_scheduled_backup() -> None:
except Exception as e:
_LOGGER.error("Scheduled backup failed: %s", e)
# --- Release-check probe -----------------------------------------------------
_RELEASE_CHECK_JOB_ID = "upstream_release_check"
_RELEASE_CHECK_ONESHOT_JOB_ID = "upstream_release_check_oneshot"
_RELEASE_CHECK_ONESHOT_DELAY_SECONDS = 30
async def _schedule_release_check() -> None:
"""Register the interval + one-shot release-check jobs.
Reads the configured interval from AppSettings at startup. Idempotent
APScheduler de-dupes via ``replace_existing=True``.
"""
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timedelta, timezone
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
from .release_check import parse_interval_hours, run_check
async with AsyncSession(get_engine()) as session:
raw = await get_setting(session, "release_check_interval_hours")
interval_hours = parse_interval_hours(raw)
scheduler = get_scheduler()
scheduler.add_job(
run_check,
IntervalTrigger(hours=interval_hours),
id=_RELEASE_CHECK_JOB_ID,
replace_existing=True,
max_instances=1,
)
# One-shot probe shortly after start so admins see a fresh status without
# waiting for the first interval tick. Mirrors the chat-title sync.
scheduler.add_job(
run_check,
"date",
run_date=datetime.now(timezone.utc) + timedelta(seconds=_RELEASE_CHECK_ONESHOT_DELAY_SECONDS),
id=_RELEASE_CHECK_ONESHOT_JOB_ID,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Scheduled release-check every %sh (one-shot in %ss)",
interval_hours, _RELEASE_CHECK_ONESHOT_DELAY_SECONDS)
async def reschedule_release_check() -> None:
"""Re-arm the release-check job after settings changed.
Called from the PUT /settings handler when the interval or provider config
changes. Removes the existing interval job, lets ``_schedule_release_check``
re-read the setting and rebuild it, and queues a fresh one-shot so the new
config takes effect within seconds rather than at the next interval tick.
"""
scheduler = get_scheduler()
if scheduler.get_job(_RELEASE_CHECK_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_JOB_ID)
if scheduler.get_job(_RELEASE_CHECK_ONESHOT_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_ONESHOT_JOB_ID)
await _schedule_release_check()
@@ -232,7 +232,9 @@ async def _poll_bot(bot_id: int) -> None:
# Copy attributes before session closes to avoid detached-instance errors
from types import SimpleNamespace
bot_token = bot.token
bot_obj = SimpleNamespace(id=bot.id, name=bot.name, token=bot.token)
bot_obj = SimpleNamespace(
id=bot.id, name=bot.name, token=bot.token, user_id=bot.user_id,
)
offset = _last_update_id.get(bot_id, 0)
@@ -331,7 +333,11 @@ async def _poll_bot(bot_id: int) -> None:
async with telegram_chat_action(
bot_token, chat_id, classify_command_chat_action(text),
):
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
responses = await handle_command(
bot_obj, chat_id, text,
language_code=effective_lang,
issuer=from_user or None,
)
if not responses:
_LOGGER.info(
"Command produced no response (cmd=%r, poll) after %.0f ms",
@@ -22,8 +22,9 @@ from ..database.models import (
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -205,11 +206,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need
# Snapshot the data we need. These reads happen INSIDE the open
# session so we get fresh attribute values; once the block exits, the
# ORM instances become detached and any unfetched attribute access
# would raise. Pulling primitives here is the deliberate isolation
# boundary between the DB phase and the network phase.
provider_type = provider.type
provider_config = dict(provider.config)
provider_name = provider.name
tracker_name = tracker.name
tracker_user_id = tracker.user_id
tracker_filters = dict(tracker.filters) if tracker.filters else {}
collection_ids = list(tracker.collection_ids or [])
@@ -317,6 +323,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
)
session.add(new_ts)
# Capture the event_log row id alongside each event so the dispatch
# loop below can stamp a "dispatch_status=deferred" pointer onto the
# row if quiet hours suppresses it.
event_log_id_by_event: dict[int, int] = {}
for event in events:
assets_count = event.added_count or event.removed_count or 0
details: dict[str, Any] = {
@@ -352,6 +362,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
details=details,
)
session.add(log)
await session.flush()
event_log_id_by_event[id(event)] = log.id
await session.commit()
@@ -377,21 +389,54 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
asset_cache=asset_cache,
session=shared_session,
)
from .deferred_dispatch import defer_event, is_deferrable
from .scheduler import schedule_deferred_drain
from ..database.models import EventLog as _EventLog
for event in events:
_LOGGER.info(
"Dispatching event %s for %s (added=%d removed=%d)",
event.event_type.value, event.collection_name,
event.added_count, event.removed_count,
)
event_log_id = event_log_id_by_event.get(id(event))
# Group targets by tracking-config identity so each unique TC
# gets one event-transform pass; targets sharing a TC dispatch
# together (preserves the gather-fan-out inside the dispatcher).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
# Track defers in a single dict so we can persist them in one
# session + commit at the end of the iteration. ``load_link_data``
# emits multiple entries per broadcast link (one per child) sharing
# the same parent ``link_id``; the deferred row is one-per-link, so
# ``dict`` keying by ``link_id`` naturally dedupes.
defers_for_event: dict[int, datetime] = {}
scheduled_until: datetime | None = None
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
# Per-link earliest fire_at wins if a future
# iteration ever supplies a different end.
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
_LOGGER.info(
" Deferred until %s (quiet hours)",
outcome.quiet_hours_end_at.isoformat() if outcome.quiet_hours_end_at else "?",
)
else:
_LOGGER.info(
" Suppressed (quiet hours; event type not deferrable)",
)
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
_LOGGER.info(" Skipped by tracking config filter")
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
@@ -410,6 +455,47 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp the event_log row + schedule drains in a
# single transaction. This keeps the "deferred" pill on the
# dashboard consistent with the existence of pending rows even if
# the process is killed mid-way (either both land or neither does).
if defers_for_event:
async with AsyncSession(engine) as defer_session:
for link_id, fire_at in defers_for_event.items():
await defer_event(
defer_session,
event=event,
user_id=tracker_user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
if scheduled_until is None or fire_at < scheduled_until:
scheduled_until = fire_at
# Stamp event_log row inside the SAME session so the
# "deferred until" pill is only visible if the rows
# actually persist.
if event_log_id is not None and scheduled_until is not None:
el = await defer_session.get(_EventLog, event_log_id)
if el is not None:
existing = dict(el.details or {})
if not existing.get("dispatch_status"):
existing["dispatch_status"] = "deferred"
existing["deferred_until"] = scheduled_until.isoformat()
el.details = existing
defer_session.add(el)
await defer_session.commit()
# Drain job registration is best-effort: a failure here just
# delays delivery until the next scan/restart, not data loss.
for fire_at in {*defers_for_event.values()}:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
for tc, target_configs in groups.values():
if not target_configs:
continue
@@ -0,0 +1,83 @@
"""Server version resolution.
Production Docker images install the wheel and ``importlib.metadata`` is the
truth. Editable dev installs (``pip install -e packages/server``) record the
version at install time and *don't auto-refresh* when the source ``pyproject.toml``
bumps so a developer that bumped from 0.3.x to 0.7.x without reinstalling
will keep reporting 0.3.x via ``importlib.metadata``.
To make the running app match the source tree without forcing a reinstall,
we read both and return the higher of the two. The dist-info wins in prod
(no pyproject alongside), the source wins in dev when the editable install is
stale.
"""
from __future__ import annotations
import logging
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
_PACKAGE_NAME = "notify-bridge-server"
_UNKNOWN = "0.0.0+unknown"
def _read_source_version() -> str | None:
"""Best-effort read of the source ``pyproject.toml`` version.
Returns ``None`` when the file isn't reachable (the normal prod case),
so callers fall back to the installed metadata.
"""
# Module is at packages/server/src/notify_bridge_server/version.py,
# pyproject sits at packages/server/pyproject.toml — three parents up.
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib # Python 3.11+ stdlib — server requires 3.12.
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
version = data.get("project", {}).get("version")
return str(version) if version else None
except (OSError, ValueError) as err: # pragma: no cover — defensive
_LOGGER.debug("Could not read source pyproject version: %s", err)
return None
def _segments(version: str) -> tuple[int, ...]:
"""Best-effort tuple-of-ints for ordering. Suffixes (``-rc1``) are stripped."""
if not version:
return ()
head = version.split("+", 1)[0].split("-", 1)[0]
out: list[int] = []
for piece in head.split("."):
digits = "".join(c for c in piece if c.isdigit())
if digits:
out.append(int(digits))
return tuple(out)
def resolve_version() -> str:
"""Return the version the running server should advertise.
Prefers the highest of (installed metadata, source pyproject) so an
out-of-date editable install never lies to the UI. In production builds
only the installed metadata is available, which is correct by definition.
"""
try:
installed: str | None = _pkg_version(_PACKAGE_NAME)
except PackageNotFoundError:
installed = None
source = _read_source_version()
candidates = [v for v in (installed, source) if v]
if not candidates:
return _UNKNOWN
if len(candidates) == 1:
return candidates[0]
# Two candidates — return the higher by numeric segments. Ties: prefer
# source, since that's what the developer just edited.
a, b = candidates
return a if _segments(a) > _segments(b) else b
@@ -0,0 +1,227 @@
"""Bot command invocations must be logged to ``EventLog``.
Covers the three branches in ``handle_command``:
* ``command_handled`` a successful invocation (here exercised via the
helper directly so the test stays focused on the persistence shape).
* ``command_rate_limited`` caller hit the cooldown.
* ``command_failed`` an exception bubbled out of dispatch.
The dashboard reads these rows via ``GET /api/status`` so the test also
asserts the row is filterable by ``event_type=command_*``.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
def _bootstrap_app():
"""Bring up the app once so migrations run against the temp DB."""
from notify_bridge_server.main import app
return app
async def _seed_user_and_bot(name: str = "Test bot"):
"""Create a User + TelegramBot, return the bot row."""
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import TelegramBot, User
engine = get_engine()
async with AsyncSession(engine) as session:
user = User(username=f"u_{name}", hashed_password="x")
session.add(user)
await session.commit()
await session.refresh(user)
bot = TelegramBot(user_id=user.id, name=name, token="dummy")
session.add(bot)
await session.commit()
await session.refresh(bot)
return bot
async def _read_events(event_type: str, bot_id: int):
"""Filter by bot_id so tests don't leak rows into each other.
The temp DB is shared across tests in this module without this
filter a row left by an earlier test would make the next assertion
flaky depending on collection order.
"""
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import EventLog
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(EventLog)
.where(EventLog.event_type == event_type)
.where(EventLog.telegram_bot_id == bot_id)
)
return list(result.all())
def test_format_command_subject_no_args(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.commands.handler import _format_command_subject
assert _format_command_subject("latest", "") == "/latest"
assert _format_command_subject("help", None) == "/help"
def test_format_command_subject_with_args(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.commands.handler import _format_command_subject
assert _format_command_subject("search", "sunset") == "/search sunset"
# Trailing whitespace must not leak into the dashboard label.
assert _format_command_subject("search", "sunset ") == "/search sunset"
def test_normalize_issuer_keeps_identity_drops_extras(tmp_data_dir) -> None: # noqa: ARG001
"""Telegram ``from`` is whitelisted to identity fields only."""
from notify_bridge_server.commands.handler import _normalize_issuer
assert _normalize_issuer(None) is None
assert _normalize_issuer({}) is None
raw = {
"id": 1234,
"username": "alex",
"first_name": "Alex",
"last_name": "",
"language_code": "ru", # already captured separately — must drop
"is_premium": True, # must not leak into our log
}
assert _normalize_issuer(raw) == {
"id": 1234,
"username": "alex",
"first_name": "Alex",
}
def test_log_command_handled_persists_row(tmp_data_dir) -> None: # noqa: ARG001
"""``command_handled`` row carries bot + provenance + media count."""
import asyncio
from notify_bridge_server.commands.base import CommandResponse
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("HandledBot")
await _log_command_event(
bot=bot,
chat_id="123456",
cmd="latest",
args="",
locale="en",
event_type="command_handled",
responses=[CommandResponse(text="ok", media=[{"type": "photo"}])],
ctx_tuples=[], # universal command path: no tracker context
)
rows = await _read_events("command_handled", bot.id)
assert len(rows) == 1
row = rows[0]
assert row.user_id == bot.user_id
assert row.telegram_bot_id == bot.id
assert row.bot_name == "HandledBot"
assert row.collection_id == "123456"
assert row.collection_name == "/latest"
assert row.assets_count == 1
assert row.details["command"] == "latest"
assert row.details["chat_id"] == "123456"
assert row.details["responses_count"] == 1
asyncio.run(run())
def test_log_command_rate_limited_carries_wait_seconds(tmp_data_dir) -> None: # noqa: ARG001
import asyncio
from notify_bridge_server.commands.base import CommandResponse
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("ThrottledBot")
await _log_command_event(
bot=bot,
chat_id="42",
cmd="random",
args="",
locale="en",
event_type="command_rate_limited",
responses=[CommandResponse(text="cooldown")],
ctx_tuples=[],
extra_details={"wait_seconds": 7},
)
rows = await _read_events("command_rate_limited", bot.id)
assert len(rows) == 1
assert rows[0].details["wait_seconds"] == 7
assert rows[0].assets_count == 0 # text-only response
asyncio.run(run())
def test_log_command_failed_records_error(tmp_data_dir) -> None: # noqa: ARG001
import asyncio
from notify_bridge_server.commands.handler import _log_command_event
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("BrokenBot")
await _log_command_event(
bot=bot,
chat_id="9",
cmd="albums",
args="",
locale="ru",
event_type="command_failed",
responses=[],
ctx_tuples=[],
extra_details={"error": "RuntimeError: boom"},
)
rows = await _read_events("command_failed", bot.id)
assert len(rows) == 1
assert rows[0].details["error"] == "RuntimeError: boom"
assert rows[0].details["locale"] == "ru"
asyncio.run(run())
def test_log_command_event_handles_db_error_gracefully(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A logging failure must NOT raise — the user still gets their reply."""
import asyncio
from notify_bridge_server.commands import handler as handler_mod
from notify_bridge_server.commands.base import CommandResponse
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
bot = await _seed_user_and_bot("StillRepliesBot")
def boom() -> object:
raise RuntimeError("db gone")
monkeypatch.setattr(handler_mod, "get_engine", boom)
# Must not raise.
await handler_mod._log_command_event(
bot=bot,
chat_id="1",
cmd="help",
args="",
locale="en",
event_type="command_handled",
responses=[CommandResponse(text="hi")],
ctx_tuples=[],
)
asyncio.run(run())
@@ -0,0 +1,431 @@
"""Tests for the quiet-hours deferred-dispatch pipeline.
Covers the four behaviours that distinguish the new feature from the legacy
"drop on quiet hours" code path:
1. ``quiet_hours_status`` returns the correct UTC end datetime, including
overnight windows that wrap past midnight.
2. ``evaluate_event_gate`` distinguishes ``QUIET_HOURS`` (deferrable) from
``EVENT_TYPE_DISABLED`` (drop forever).
3. ``serialize_event`` / ``deserialize_event`` round-trip without losing
asset metadata.
4. ``defer_event`` coalesces ``assets_added`` + ``assets_removed`` of the
same IDs for the same link+collection the cancellation case that
motivated the whole feature.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.providers.base import ServiceProviderType
# ---------------------------------------------------------------------------
# Quiet-hours math
# ---------------------------------------------------------------------------
def test_quiet_hours_status_inside_normal_window(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
# Pretend it's 13:00 UTC inside a 12:00-14:00 window.
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("12:00", "14:00", "UTC")
assert end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)
def test_quiet_hours_status_start_equals_end_returns_none() -> None:
"""``00:00-00:00`` is ambiguous (single instant vs always-on); treat as no window.
Code-review feedback: without this guard, the overnight-window branch would
interpret it as "always quiet" and silently defer every notification all
day. The conservative read is that the user misconfigured and we should
behave as if quiet hours were off.
"""
from notify_bridge_server.services import dispatch_helpers as dh
assert dh.quiet_hours_status("00:00", "00:00", "UTC") is None
assert dh.quiet_hours_status("13:30", "13:30", "UTC") is None
def test_quiet_hours_status_outside_window_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 15, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
assert dh.quiet_hours_status("12:00", "14:00", "UTC") is None
def test_quiet_hours_status_overnight_window_post_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
"""22:00-06:00 window, current time 03:00 → window ends today at 06:00."""
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 3, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
assert end_at == datetime(2026, 5, 12, 6, 0, tzinfo=timezone.utc)
def test_quiet_hours_status_overnight_window_pre_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
"""22:00-06:00 window, current time 23:30 → window ends tomorrow at 06:00."""
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 23, 30, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
assert end_at == datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Gate enum / outcome
# ---------------------------------------------------------------------------
def _make_event(
event_type: EventType = EventType.ASSETS_ADDED,
*,
added_assets: list[MediaAsset] | None = None,
) -> ServiceEvent:
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
added_assets=added_assets or [],
added_count=len(added_assets or []),
)
def _make_asset(asset_id: str, *, filename: str | None = None) -> MediaAsset:
return MediaAsset(
id=asset_id,
type=MediaType.IMAGE,
filename=filename or f"{asset_id}.jpg",
created_at=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
)
class _FakeTrackingConfig:
"""Minimal stand-in for TrackingConfig — only the fields the gate reads."""
def __init__(
self,
*,
quiet_hours_enabled: bool = False,
quiet_hours_start: str | None = None,
quiet_hours_end: str | None = None,
track_assets_added: bool = True,
) -> None:
self.quiet_hours_enabled = quiet_hours_enabled
self.quiet_hours_start = quiet_hours_start
self.quiet_hours_end = quiet_hours_end
self.track_assets_added = track_assets_added
# The gate's flag map reads every track_* attribute; set the rest to
# True so it doesn't accidentally block on an unrelated event type.
for attr in (
"track_assets_removed", "track_collection_renamed",
"track_collection_deleted", "track_sharing_changed",
"track_push", "track_issue_opened", "track_issue_closed",
"track_issue_commented", "track_pr_opened", "track_pr_closed",
"track_pr_merged", "track_pr_commented", "track_release_published",
"track_card_created", "track_card_updated", "track_card_moved",
"track_card_deleted", "track_card_commented", "track_comment_updated",
"track_board_created", "track_board_updated", "track_board_deleted",
"track_list_created", "track_list_updated", "track_list_deleted",
"track_attachment_created", "track_card_label_added",
"track_task_completed", "track_scheduled_message",
"track_webhook_received", "track_ups_online", "track_ups_on_battery",
"track_ups_low_battery", "track_ups_battery_restored",
"track_ups_comms_lost", "track_ups_comms_restored",
"track_ups_replace_battery", "track_ups_overload",
):
setattr(self, attr, True)
def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
tc = _FakeTrackingConfig(
quiet_hours_enabled=True,
quiet_hours_start="12:00",
quiet_hours_end="14:00",
# Even with the event-type flag flipped off, quiet hours should be
# the reported reason — it's the "louder" gate. The downstream defer
# path treats this as a deferral candidate; flipping the order would
# silently drop deferrable events when both gates are closed.
track_assets_added=False,
)
outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
assert outcome.reason is dh.GateReason.QUIET_HOURS
assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)
def test_gate_event_type_disabled_when_quiet_hours_off() -> None:
from notify_bridge_server.services import dispatch_helpers as dh
tc = _FakeTrackingConfig(quiet_hours_enabled=False, track_assets_added=False)
outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
assert outcome.reason is dh.GateReason.EVENT_TYPE_DISABLED
assert outcome.quiet_hours_end_at is None
# ---------------------------------------------------------------------------
# Event payload round-trip
# ---------------------------------------------------------------------------
def test_serialize_deserialize_roundtrips_assets_and_extras() -> None:
from notify_bridge_server.services import deferred_dispatch as dd
asset = _make_asset("a1")
asset.extra = {"city": "Minsk", "is_favorite": True, "rating": 5}
event = _make_event(added_assets=[asset])
event.extra = {"people": ["Alice"]}
payload = dd.serialize_event(event)
restored = dd.deserialize_event(payload)
assert restored.event_type is EventType.ASSETS_ADDED
assert restored.provider_type is ServiceProviderType.IMMICH
assert restored.collection_id == "col-1"
assert len(restored.added_assets) == 1
assert restored.added_assets[0].id == "a1"
assert restored.added_assets[0].extra["city"] == "Minsk"
assert restored.extra["people"] == ["Alice"]
assert restored.timestamp == event.timestamp
# ---------------------------------------------------------------------------
# Coalescing — the add-then-remove cancellation that motivated the design
# ---------------------------------------------------------------------------
@pytest.fixture
async def empty_session():
"""In-memory SQLite session for coalescing tests — no fixtures, just a clean DB."""
# Importing models here registers them on SQLModel.metadata. We rely on
# ``DeferredDispatch`` being declared so create_all picks it up.
from notify_bridge_server.database import models # noqa: F401 — side effect
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_add_then_remove_same_assets_cancels_pending(empty_session: AsyncSession) -> None:
"""User adds {A, B}, then removes {A, B} — both pending rows should disappear.
Before this feature this scenario would either spam two late notifications
("added" then "removed") or silently drop both. The cancellation path is
the win that justified the coalescing module.
"""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
add_event = _make_event(
EventType.ASSETS_ADDED,
added_assets=[_make_asset("A"), _make_asset("B")],
)
result = await dd.defer_event(
empty_session,
event=add_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
assert result == "inserted"
remove_event = ServiceEvent(
event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
removed_asset_ids=["A", "B"],
removed_count=2,
)
result = await dd.defer_event(
empty_session,
event=remove_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
pending = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
assert pending == [], "add-then-remove of same IDs should leave the queue empty"
@pytest.mark.asyncio
async def test_add_then_partial_remove_keeps_remainder(empty_session: AsyncSession) -> None:
"""User adds {A, B, C}, then removes {B} — pending row should contain {A, C}."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[
_make_asset("A"), _make_asset("B"), _make_asset("C"),
]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
remove_event = ServiceEvent(
event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
removed_asset_ids=["B"],
removed_count=1,
)
await dd.defer_event(
empty_session,
event=remove_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
# Only the assets_added row survives (B subtracted). No assets_removed
# row because B was just added — its removal is a wash.
assert len(rows) == 1
assert rows[0].event_type == "assets_added"
remaining_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
assert remaining_ids == ["A", "C"]
@pytest.mark.asyncio
async def test_add_then_add_unions_assets(empty_session: AsyncSession) -> None:
"""Two consecutive assets_added events should merge into one pending row."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[_make_asset("A")]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[
_make_asset("B"), _make_asset("C"),
]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
assert len(rows) == 1
merged_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
assert merged_ids == ["A", "B", "C"]
@pytest.mark.asyncio
async def test_non_asset_event_is_not_coalesced(empty_session: AsyncSession) -> None:
"""Two push events for the same repo should both be queued — historical facts."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
for i in range(2):
push_event = ServiceEvent(
event_type=EventType.PUSH,
provider_type=ServiceProviderType.GITEA,
provider_name="test-gitea",
collection_id="repo-1",
collection_name="my/repo",
timestamp=datetime(2026, 5, 12, 12, i, tzinfo=timezone.utc),
extra={"commit_sha": f"sha{i}"},
)
await dd.defer_event(
empty_session,
event=push_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100 + i, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
# Both rows survive — pushes don't cancel one another.
assert len(rows) == 2
@pytest.mark.asyncio
async def test_scheduled_message_is_non_deferrable(empty_session: AsyncSession) -> None:
"""``scheduled_message`` is wall-clock — defer_event should refuse to enqueue."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
sched_event = ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.SCHEDULER,
provider_name="sched",
collection_id="",
collection_name="",
timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
)
result = await dd.defer_event(
empty_session,
event=sched_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100,
fire_at=datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc),
)
assert result == "non_deferrable"
await empty_session.commit()
rows = (await empty_session.exec(select(DeferredDispatch))).all()
assert rows == []
@@ -0,0 +1,235 @@
"""Tests for the release provider abstraction and Gitea probe."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from notify_bridge_core.release import build_release_provider, is_valid_repo
from notify_bridge_core.release.base import (
ReleaseErrorCode,
ReleaseProviderKind,
compare_versions,
is_newer,
normalise_version,
)
from notify_bridge_core.release.gitea import GiteaReleaseProvider
# --- pure utilities ---------------------------------------------------------
def test_normalise_version_strips_v_prefix() -> None:
assert normalise_version("v1.2.3") == "1.2.3"
assert normalise_version("V1.2.3") == "1.2.3"
assert normalise_version("1.2.3") == "1.2.3"
assert normalise_version("") == ""
# Only strip ``v`` when followed by a digit — guard against names like
# ``vendor-1`` being mangled into ``endor-1``.
assert normalise_version("vendor-1") == "vendor-1"
@pytest.mark.parametrize(
("a", "b", "expected"),
[
("0.7.3", "0.7.2", 1),
("0.7.2", "0.7.3", -1),
("0.7.2", "0.7.2", 0),
("v0.7.3", "0.7.2", 1),
("1.0.0", "0.9.99", 1),
# Stable beats prerelease at equal numerics (tie-break).
("0.7.2-rc1", "0.7.2", -1),
("0.7.2", "0.7.2-rc1", 1),
# Implicit prerelease form ``1.0a2`` must NOT extract ``2`` as a
# third numeric segment — equal to ``1.0`` stable, then stable wins.
("1.0a2", "1.0", -1),
("", "0.0.0", 0),
],
)
def test_compare_versions(a: str, b: str, expected: int) -> None:
assert compare_versions(a, b) == expected
def test_is_newer_is_strict() -> None:
assert is_newer("0.7.3", "0.7.2") is True
assert is_newer("0.7.2", "0.7.2") is False
# A pre-release of the next minor should still be flagged as newer when
# explicitly fetched with include_prereleases=True at the provider level.
assert is_newer("0.7.3-rc1", "0.7.2") is True
def test_is_valid_repo() -> None:
assert is_valid_repo("alexei.dolgolyov/notify-bridge") is True
assert is_valid_repo("a/b") is True
assert is_valid_repo("a_b/c.d-e") is True
assert is_valid_repo("") is False
assert is_valid_repo("no-slash") is False
# Path-traversal attempts.
assert is_valid_repo("foo/bar/../admin") is False
assert is_valid_repo("foo/bar/baz") is False
assert is_valid_repo("foo/../bar") is False
# Embedded special chars.
assert is_valid_repo("foo@bar/baz") is False
assert is_valid_repo("foo/bar?x=1") is False
# --- registry ---------------------------------------------------------------
def test_registry_returns_none_for_disabled() -> None:
assert build_release_provider("disabled", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_returns_none_for_unknown_kind() -> None:
assert build_release_provider("svn", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_gitea_requires_url_and_valid_repo() -> None:
sess = MagicMock()
assert build_release_provider("gitea", session=sess, url="", repo="a/b") is None
assert build_release_provider("gitea", session=sess, url="https://x", repo="") is None
# Path traversal blocked by repo validation.
assert build_release_provider("gitea", session=sess, url="https://x", repo="a/b/../c") is None
provider = build_release_provider("gitea", session=sess, url="https://x", repo="a/b")
assert isinstance(provider, GiteaReleaseProvider)
assert provider.kind is ReleaseProviderKind.GITEA
# --- Gitea provider ---------------------------------------------------------
def _gitea_payload(**overrides: Any) -> list[dict[str, Any]]:
base = {
"tag_name": "v0.7.3",
"name": "v0.7.3",
"html_url": "https://git.example.com/owner/repo/releases/tag/v0.7.3",
"body": "Notes",
"published_at": "2026-05-01T00:00:00Z",
"draft": False,
"prerelease": False,
}
base.update(overrides)
return [base]
class _FakeContent:
def __init__(self, raw: bytes) -> None:
self._raw = raw
async def read(self, n: int = -1) -> bytes:
return self._raw if n < 0 else self._raw[:n]
class _FakeResponse:
def __init__(self, status: int, payload: Any) -> None:
self.status = status
import json
self.content = _FakeContent(json.dumps(payload).encode("utf-8"))
self._payload = payload
async def json(self) -> Any:
return self._payload
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def _session_with(payload: Any, status: int = 200) -> MagicMock:
"""Return a session whose `.get()` yields a fresh response per call.
Using ``side_effect`` rather than ``return_value`` ensures multiple
awaited fetches don't share mutable response state across tests.
"""
sess = MagicMock()
sess.get = MagicMock(side_effect=lambda *a, **kw: _FakeResponse(status, payload))
return sess
@pytest.fixture(autouse=True)
def _allow_private_urls(monkeypatch: pytest.MonkeyPatch) -> None:
"""SSRF guard rejects example.com → publicly resolvable, so tests pass.
But we explicitly enable the bypass to remove DNS-resolution flakiness
from CI runs.
"""
monkeypatch.setenv("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS", "1")
# Reload the ssrf module to pick up the env var (it's read at import).
import importlib
import notify_bridge_core.notifications.ssrf as ssrf_mod
importlib.reload(ssrf_mod)
async def test_gitea_fetch_latest_happy_path() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://git.example.com/", "owner/repo")
info = await provider.fetch_latest(include_prereleases=False)
assert info is not None
assert info.tag == "v0.7.3"
assert info.version == "0.7.3"
assert info.url == "https://git.example.com/owner/repo/releases/tag/v0.7.3"
assert info.prerelease is False
async def test_gitea_skips_prereleases_by_default() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=False) is None
async def test_gitea_includes_prereleases_when_asked() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
info = await provider.fetch_latest(include_prereleases=True)
assert info is not None
assert info.prerelease is True
async def test_gitea_skips_drafts() -> None:
payload = _gitea_payload(draft=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=True) is None
async def test_gitea_returns_none_on_http_error() -> None:
sess = _session_with([], status=500)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest() is None
async def test_gitea_test_returns_structured_status() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is True
assert result["info"] is not None
assert result["error"] is None
async def test_gitea_test_reports_http_error() -> None:
sess = _session_with([], status=404)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is False
assert result["info"] is None
# Taxonomy code, not a raw exception string.
assert result["error"] in {code.value for code in ReleaseErrorCode}
def test_gitea_constructor_validates_repo_format() -> None:
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "no-slash")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "foo/bar/../baz")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "", "owner/repo")
@@ -0,0 +1,144 @@
"""Tests for the release_check service (interval clamping + status endpoints + persistence)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
def test_parse_interval_hours_clamps_and_defaults() -> None:
from notify_bridge_server.services.release_check import parse_interval_hours
assert parse_interval_hours("12") == 12
assert parse_interval_hours("") == 12 # default
assert parse_interval_hours(None) == 12
assert parse_interval_hours("0") == 1 # clamped to min
assert parse_interval_hours("9999") == 168 # clamped to max
assert parse_interval_hours("not-a-number") == 12 # fallback to default
assert parse_interval_hours("24") == 24
def test_release_endpoint_anonymous_is_rejected(tmp_data_dir) -> None: # noqa: ARG001
"""GET /api/settings/release requires auth — same as other settings."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get("/api/settings/release")
# Either 401 (missing token) or 403 (not authenticated) is acceptable.
assert resp.status_code in (401, 403)
def test_release_force_check_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post("/api/settings/release/check")
assert resp.status_code in (401, 403)
def test_release_test_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post(
"/api/settings/release/test",
json={"provider_kind": "gitea", "provider_url": "https://x.example.com", "provider_repo": "a/b"},
)
assert resp.status_code in (401, 403)
# --- Persistence round-trip -------------------------------------------------
@pytest.mark.asyncio
async def test_persist_release_state_round_trip(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""Write a fake ReleaseInfo, read it back via load_status, assert flags."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
info = ReleaseInfo(
tag="v0.9.0",
version="0.9.0",
name="0.9.0 — Aurora",
body="Release notes",
url="https://example.com/x/y/releases/tag/v0.9.0",
published_at="2026-06-01T00:00:00Z",
prerelease=False,
draft=False,
)
await persist_release_state(
checked_at="2026-06-01T00:01:00+00:00",
error=None,
info=info,
)
# Force the comparator to see an older "current" so update_available
# comes out True regardless of the actual installed package version.
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest == "0.9.0"
assert status.latest_tag == "v0.9.0"
assert status.update_available is True
assert status.error is None
assert status.latest_body == "Release notes"
@pytest.mark.asyncio
async def test_persist_release_state_clears_on_none_info(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A persist call with ``info=None`` must blank all the latest-* fields."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
# Seed a populated row.
await persist_release_state(
checked_at="2026-06-01T00:00:00+00:00",
error=None,
info=ReleaseInfo(tag="v9.9.9", version="9.9.9"),
)
# Now wipe by passing info=None — mimics the "provider_changed" flow.
await persist_release_state(
checked_at="2026-06-01T00:02:00+00:00",
error="provider_changed",
info=None,
)
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest is None
assert status.latest_tag is None
assert status.update_available is False
assert status.error == "provider_changed"
# --- Version resolver -------------------------------------------------------
def test_resolve_version_prefers_source_pyproject() -> None:
"""When pyproject.toml is alongside the source, prefer the higher of (installed, source)."""
from notify_bridge_server.version import resolve_version
v = resolve_version()
assert v != "0.0.0+unknown"
# If the editable install is stale (e.g. 0.3.2) but pyproject says 0.7.2,
# resolve_version must return 0.7.2 (or higher) — the resolver's
# whole purpose. We test the "not stale" half of the contract here.
parts = v.split(".")
assert len(parts) >= 2
assert parts[0].isdigit()
@@ -0,0 +1,195 @@
"""Tests for the generic-webhook ``/status`` command handler.
The webhook provider has no upstream API, so ``/status`` is built from
local DB stats:
* ``trackers_active`` count of enabled ``NotificationTracker`` rows
* ``trackers_total`` count of all ``NotificationTracker`` rows
* ``last_event`` formatted timestamp of the most recent ``EventLog`` row
tied to one of the provider's trackers, or ``-`` when there are none
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlmodel.ext.asyncio.session import AsyncSession
_STATUS_TEMPLATE_EN = (
"Webhook Status\n"
"Trackers active: {{ trackers_active }}/{{ trackers_total }}\n"
"Provider: {{ provider_name }}\n"
"Last event: {{ last_event }}"
)
def _bootstrap_app():
"""Bring up the app once so migrations run against the temp DB."""
from notify_bridge_server.main import app
return app
async def _seed_user() -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import User
engine = get_engine()
async with AsyncSession(engine) as session:
user = User(username=f"u_{datetime.now(timezone.utc).timestamp()}", hashed_password="x")
session.add(user)
await session.commit()
await session.refresh(user)
return user.id
async def _seed_provider(user_id: int, name: str = "WH") -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import ServiceProvider
engine = get_engine()
async with AsyncSession(engine) as session:
prov = ServiceProvider(user_id=user_id, type="webhook", name=name, config={})
session.add(prov)
await session.commit()
await session.refresh(prov)
return prov.id
async def _seed_tracker(user_id: int, provider_id: int, name: str, *, enabled: bool) -> int:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import NotificationTracker
engine = get_engine()
async with AsyncSession(engine) as session:
tr = NotificationTracker(
user_id=user_id, provider_id=provider_id, name=name, enabled=enabled,
)
session.add(tr)
await session.commit()
await session.refresh(tr)
return tr.id
async def _seed_event(tracker_id: int, when: datetime) -> None:
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import EventLog
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
tracker_id=tracker_id,
tracker_name="webhook-tr",
event_type="webhook_received",
collection_id="",
collection_name="",
created_at=when,
))
await session.commit()
async def _load_provider(provider_id: int):
from notify_bridge_server.database.engine import get_engine
from notify_bridge_server.database.models import ServiceProvider
engine = get_engine()
async with AsyncSession(engine) as session:
return await session.get(ServiceProvider, provider_id)
def test_dispatch_registers_webhook_handler(tmp_data_dir) -> None: # noqa: ARG001
"""Auto-registration must wire the webhook handler to provider type 'webhook'."""
from notify_bridge_server.commands.dispatch import get_handler
handler = get_handler("webhook")
assert handler is not None, "WebhookCommandHandler must be registered"
assert handler.provider_type == "webhook"
assert "status" in handler.get_provider_commands()
def test_status_renders_active_total_and_last_event(tmp_data_dir) -> None: # noqa: ARG001
"""``/status`` returns active/total counts and formatted last-event timestamp."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH1")
enabled_id = await _seed_tracker(user_id, provider_id, "tr-on", enabled=True)
await _seed_tracker(user_id, provider_id, "tr-off", enabled=False)
event_at = datetime(2026, 5, 1, 12, 34, tzinfo=timezone.utc)
await _seed_event(enabled_id, event_at)
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
response = await handler.handle(
cmd="status", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates=templates,
bot=None, tracker=None, config=None,
)
assert response is not None
assert response.text is not None
text = response.text
assert "Trackers active: 1/2" in text
assert "Provider: WH1" in text
assert "2026-05-01 12:34" in text
asyncio.run(run())
def test_status_with_no_events_shows_dash(tmp_data_dir) -> None: # noqa: ARG001
"""Zero events → ``last_event`` renders as '-' (the get_last_event_str sentinel)."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH-empty")
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
templates = {"status": {"en": _STATUS_TEMPLATE_EN}}
response = await handler.handle(
cmd="status", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates=templates,
bot=None, tracker=None, config=None,
)
assert response is not None
assert "Trackers active: 0/0" in response.text
assert "Last event: -" in response.text
asyncio.run(run())
def test_status_returns_none_for_unknown_command(tmp_data_dir) -> None: # noqa: ARG001
"""Commands the webhook handler doesn't own must return None (lets dispatch fall through)."""
from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler
app = _bootstrap_app()
with TestClient(app):
async def run() -> None:
user_id = await _seed_user()
provider_id = await _seed_provider(user_id, "WH-noop")
provider = await _load_provider(provider_id)
handler = WebhookCommandHandler()
response = await handler.handle(
cmd="albums", args="", count=5, locale="en",
response_mode="text", provider=provider,
cmd_templates={},
bot=None, tracker=None, config=None,
)
assert response is None
asyncio.run(run())