Compare commits

...

15 Commits

Author SHA1 Message Date
alexei.dolgolyov 85a8f1e71c chore: release v0.8.2
Release / test-backend (push) Successful in 2m20s
Release / release (push) Successful in 1m40s
2026-05-22 22:54:00 +03:00
alexei.dolgolyov 2d59a5b994 fix: production-readiness hardening from full-codebase review
Apply six isolated, low-risk fixes surfaced by the parallel
production-readiness review (backend, frontend, security, perf,
UI/UX, bugs+features).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Gate the dispatch in `dispatch_scheduled_for_tracker` for kind=periodic
via `(today_in_app_tz - start_date).days % interval == 0`. Log a skip
with reason `interval_not_due` on non-firing days so operators can tell
suppressed-by-interval apart from other skip causes.
2026-05-13 12:14:18 +03:00
alexei.dolgolyov dec0839853 feat: on-watch stats scope selector (page vs all)
Adds an icon selector to the "On watch" provider deck letting users
choose between page-scoped stats (legacy) and full-corpus stats that
aggregate across every event matching the current filters. Backend
returns a new provider_event_counts map alongside the paginated events.
2026-05-12 14:12:59 +03:00
alexei.dolgolyov dfd7329177 chore: release v0.8.0
Release / release (push) Successful in 1m55s
2026-05-12 03:01:06 +03:00
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00
alexei.dolgolyov bb5afcc222 docs: expand README with all providers, targets, bot commands, and smart actions 2026-05-11 22:21:51 +03:00
alexei.dolgolyov 4335036c22 docs: sync README deploy section with actual env vars
Fix CORS default (was incorrectly listed as `*`, which is rejected on
startup) and document the env vars exposed by config.py and
docker-compose.yml — proxy/SSRF, auth, logging, retention, and
integration settings. Sync the Docker Compose example with the
hardened compose file at the repo root.
2026-05-11 21:50:31 +03:00
215 changed files with 16592 additions and 1144 deletions
+177
View File
@@ -0,0 +1,177 @@
# Feature Backlog
Curated feature ideas, narrowed from a brainstorming pass on 2026-05-13.
Order is **rough sequencing preference**, not strict priority — adjust as we go.
---
## 1. Quiet Hours — close the gaps in the existing system
**Reality check (verified 2026-05-13).** Quiet hours are already shipped under
the "deferred dispatch" name in v0.8.0. The pipeline lives at
`packages/server/src/notify_bridge_server/services/deferred_dispatch.py` with
helpers in `dispatch_helpers.py` and tests in
`tests/test_deferred_dispatch.py`. What exists:
- Per-tracking-config window: `tracking_config.quiet_hours_enabled`,
`quiet_hours_start`, `quiet_hours_end`.
- Per-link override: `notification_tracker_target.quiet_hours_start`,
`quiet_hours_end`.
- Smart coalescing (asset add + asset remove during a window cancels each
other out, set-union merge for repeated adds).
- Post-window drain via APScheduler one-shot date jobs.
- Wall-clock event types (`scheduled_message`) drop instead of deferring.
- Frontend status surface: `deferred`, `deferred_then_dropped`,
`deferred_then_failed`, with `deferred_until` and `deferred_for_seconds`
fields exposed in the event log.
**What's NOT there (the actual gaps):**
| Gap | Sketch |
| --- | --- |
| **Target-level windows** | Today, hours bind to the *watcher* (tracking config / link). Users naturally think of DND at the *destination* ("don't ping my phone at night, regardless of source"). New column on `notification_target` + dispatcher gate. |
| **Multiple windows per row** | Today is a single HH:MM range. Real schedules want weekday-evening + weekend-all-day. JSON list of windows. |
| **Days-of-week** | Same window every day. Need `days: ["mon", "tue", ...]` filter per window. |
| **Per-window timezone** | Uses the global app TZ. Multi-traveller / multi-target setups want per-window TZ. |
| **Silent mode** | Modes today are defer-or-drop. Telegram `disable_notification=true` ("send but don't ring") is a third useful mode. |
| **Per-receiver windows** | One bot → multiple chats, each potentially with its own DND. Today it's all-or-nothing per target. |
**Recommended cut for v1 of "extend quiet hours":**
- Add target-level quiet hours (new column `notification_target.quiet_hours_json`
= list of `{days, start, end, mode, tz}`).
- Modes: `drop`, `defer`, `silent`. `defer` reuses the existing
deferred-dispatch pipeline (just changes who decides). `silent` maps to
`disable_notification=true` for Telegram; other targets fall through to
normal send (or we treat `silent` as `defer` for non-Telegram targets — TBD).
- Dispatcher precedence: target window wins over link/tracking-config window
when both are configured. Document this explicitly.
- Frontend: new "Quiet hours" fieldset in the target editor (Aurora cassette
style). Reuses Timezone picker; new day-picker chip row.
- Skip days-of-week + multi-window in v1 if scope grows — ship the target-level
cut first, then iterate.
**Open questions.**
- How exactly do target / link / tracking-config windows combine? Proposal:
any window covering "now" wins (drop > defer > silent precedence).
- Should `silent` for non-Telegram targets degrade to normal send or to
defer? Defer is the safer default.
- Does the event log need a new status (`silenced` / `dropped_by_target_qh`)
to make precedence visible?
---
## 2. Immich Smart Actions (expand beyond Auto-Organize)
**What.** Extend the existing Smart Actions pattern (currently:
**Immich Auto-Organize**) with more rule-driven actions against the Immich API.
**Why.** Auto-Organize already proves the descriptor → rule editor → executor
pipeline. Adding actions is mostly authoring new executors + small UI rule
shapes, not new infra.
**Candidates (pick in this order).**
1. **Auto-favorite by person** — when an asset is detected containing person
X (or any of a set), mark it favorite.
2. **Auto-archive by age / album** — assets older than N days in a given
album get archived. Pair with a "dry-run shows count" UX like
Auto-Organize already has.
3. **Duplicate cluster nudge** — periodically run Immich's duplicate API and
send a digest notification with inline buttons ("review", "ignore for 30d").
Depends on inline-button work (see backlog item 4 dependencies).
4. **Share-link rotation** — for an album, regenerate the share link every N
days; notify with the new URL.
5. **Pending-delete review** — push a weekly digest of trash contents before
Immich's auto-purge fires.
**Shape.**
- Reuse the existing **action descriptor** layer
(`packages/core/src/notify_bridge_core/providers/actions.py`,
`action_executor.py`) and the frontend rule editor used by Auto-Organize.
- Each new action = (a) executor in core, (b) rule schema in the descriptor,
(c) frontend descriptor extension for the rule editor fields.
- Persist as `provider_actions` rows (already exists for Auto-Organize) with
a discriminator + JSON config.
**Open questions.**
- Does "auto-favorite by person" need a confirmation queue or run silently?
Default to silent + event_log entry.
- How do we surface "this action moved/changed X assets" in the dashboard?
Probably a per-action stat tile on the provider detail page.
---
## 3. Home Assistant Provider
**Full plan:** [feature-home-assistant.md](feature-home-assistant.md).
**One-line summary.** New WebSocket-based service provider with a 3-phase
ship: subscribe + dispatch (Phase 1), bot commands (Phase 2), HA service
calls as Smart Actions (Phase 3). Chosen over webhook ingest because
Phases 2 + 3 force a long-lived API connection anyway; consolidating on WS
avoids a refactor.
**Status:** planned, not started.
---
## 4. Block-Based Template Builder
**What.** A visual, drag-and-drop builder for notification and command
templates that compiles down to Jinja2. Lives alongside (not instead of) the
current `JinjaEditor`. Author can flip between views.
**Why.** The current Jinja editor is powerful but unforgiving. A block UI
lowers the floor for new users and provides a discovery surface for the
variables documented in `template_configs.py`.
**Shape.**
- Frontend-only feature for v1 — compiles to the same Jinja strings the
backend already accepts.
- Blocks: `Text`, `Variable`, `If`, `For`, `Link`, `Image`, `Icon`, `Caption`,
`Group` (HTML span/group). Each block knows its serialized Jinja
representation.
- Round-trip: variables, simple `{% if %}` / `{% for %}` blocks, and string
literals parse back to blocks; arbitrary Jinja stays in a "Raw" block that
the user can edit as text.
- Variable picker reads `get_template_variables(provider_type, slot)`. This is
the same data already shown in the template-help panel.
- Preview pane unchanged — reuses `services/sample_context.py` server
rendering.
- Toggle in the template editor: **Visual / Code**.
**Open questions.**
- Round-tripping arbitrary Jinja is hard. v1: parseable subset → blocks,
anything else → single Raw block. Show a banner explaining.
- Locale handling: same compiled Jinja, just authored per locale tab.
- Do we want a marketplace of pre-built block compositions? Out of scope for
v1 — bundle import/export is a separate backlog item.
---
## Recommended Sequencing
1. **Quiet Hours per Target** — small, isolated, immediate user value.
2. **Immich Smart Actions** — incremental on existing pattern; ship one
action at a time (start with auto-favorite by person).
3. **Home Assistant Provider** — multi-file, follows new-provider checklist;
biggest user-base expansion.
4. **Block-Based Template Builder** — largest frontend lift; benefits from
the variable-doc work that the other features will exercise.
Dependencies are loose — 1 and 2 are independent of 3 and 4. The block
builder pairs nicely with Home Assistant because HA's rich context surfaces
the value of an easier authoring UX.
---
## Decision log
- **2026-05-13** — Backlog seeded with these four items selected from a
broader brainstorm. Not started.
+284
View File
@@ -0,0 +1,284 @@
# Home Assistant Provider — Implementation Plan
> Status: **planned, not started**. Sequencing: third item on the backlog
> (see [feature-backlog.md](feature-backlog.md)).
> Last updated: 2026-05-13.
## Decision: WebSocket subscription, not webhook
We considered three ingest modes (webhook automation, WebSocket subscription,
hybrid). The WebSocket route is chosen as the architectural foundation because
the medium-term roadmap forces it anyway:
| Phase | Capability | Needs API access? |
| --- | --- | --- |
| 1 | Subscribe to events, emit notifications | Read (event stream) |
| 2 | Bot commands (`/state`, `/entities`, `/areas`) | Read (REST or WS get_states) |
| 3 | Smart Actions (`light.turn_on`, scene activation) | Write (call_service) |
A webhook-only Phase 1 would still need a REST client by Phase 2 and a write
path by Phase 3 — net result is two client implementations + one event
pipeline refactor. WebSocket consolidates all three phases on one connection.
**Tradeoff (be honest):** WebSocket introduces a long-lived-connection pattern
this codebase does not have yet. Reconnect logic, missed-events-on-restart
gap, and a new shape on the `ServiceProvider` ABC are real costs. Phase 1 is
**not** shippable in one short session — plan for a multi-session build.
## Provider abstraction extension
The current `ServiceProvider` ABC
([packages/core/src/notify_bridge_core/providers/base.py](../../packages/core/src/notify_bridge_core/providers/base.py))
is poll-oriented: every provider implements `poll(collection_ids, state) →
(events, new_state)`. Webhook providers (Gitea, Planka, Webhook) satisfy this
by no-op'ing `poll` and shoving events in via `api/webhooks.py` instead.
Home Assistant fits neither cleanly. The plan:
1. Add an **optional** `async subscribe(emit) → None` method on the base ABC.
Default implementation raises `NotImplementedError`. Polling providers do
not override it. The scheduler / lifecycle layer (currently `services/watcher.py`)
gains a "subscription manager" branch that, for any provider whose class
overrides `subscribe`, starts a long-lived task instead of registering
a polling job.
2. `emit` is a callback `(event: ServiceEvent) → None` provided by the
subscription manager — it routes events to the dispatcher exactly like the
webhook handler does today. Keeping the dispatch path unchanged is the
point of this design.
3. Reconnect lives **inside** `subscribe`: the method is expected to be a
`while not cancelled: try connect; on drop, sleep with backoff, retry`
loop. The manager cancels the task on shutdown via the cooperative cancel
token used elsewhere.
This is a small, additive change to one ABC. No existing provider is
modified.
## Phase 1 — Subscribe + Dispatch (MVP)
### Scope
- Long-lived WebSocket connection to HA, authenticated with a long-lived
access token.
- Subscribe to the event bus with optional `event_type` filter (defaults to
`state_changed`).
- Translate HA events into `ServiceEvent` and dispatch via the existing
pipeline. Notifications go out exactly as they do today for any other
provider.
- Filter UI: entity-id glob list, domain allowlist (e.g. `light.*`,
`binary_sensor.*`), event-type allowlist. **Hard-required** to avoid the HA
firehose drowning the bridge.
- Connection test + entity listing via WS `get_states` (no REST client yet —
WS gives us both subscribe and read).
### Out of scope for Phase 1
- Bot commands → Phase 2.
- Service calls → Phase 3.
- Replay of events missed during disconnect (HA does not support this; we
document the gap and surface "reconnected after N seconds" in the event
log).
- Webhook-style ingestion (path-embedded token webhook receiver). If a user
prefers webhooks, we add it later as a second ingestion mode on the same
provider — out of scope for v1.
### Event types (v1)
| HA event | ServiceEvent type | Notification slot |
| --- | --- | --- |
| `state_changed` | `ha_state_changed` | `message_state_changed` |
| `automation_triggered` | `ha_automation_triggered` | `message_automation_triggered` |
| `call_service` | `ha_service_called` | `message_service_called` |
| (custom event types) | `ha_event_fired` | `message_event_fired` |
Default tracking config enables `state_changed` only — the others are loud
and opt-in.
### Context variables exposed to templates
Pulled directly from HA's `state_changed` payload, normalized:
- `entity_id``light.kitchen`
- `friendly_name``attributes.friendly_name` or fallback to `entity_id`
- `domain` — derived from `entity_id` before the dot
- `old_state``from_state.state`
- `new_state``to_state.state`
- `attributes` — dict of new-state attributes (raw)
- `device_class``attributes.device_class` if present
- `area``attributes.area_id` if present (best effort; only set if HA
exposes it via the area registry, which costs a `get_registry` WS call —
see "Open questions")
- `last_changed`, `last_updated` — ISO timestamps
- For non-`state_changed` events: `event_type`, `event_data` (full dict)
### File touch map (Phase 1)
**Core** (`packages/core/src/notify_bridge_core/`)
| Path | Action | Notes |
| --- | --- | --- |
| `providers/base.py` | Modify | Add optional `subscribe(emit)` ABC method (default `NotImplementedError`); add `HOME_ASSISTANT = "home_assistant"` to `ServiceProviderType` |
| `providers/capabilities.py` | Modify | Add `HOME_ASSISTANT_CAPABILITIES` + register |
| `providers/home_assistant/__init__.py` | Create | Export + register template variables |
| `providers/home_assistant/client.py` | Create | WebSocket client (auth, subscribe, get_states, call_service stub) |
| `providers/home_assistant/event_parser.py` | Create | HA event dict → `ServiceEvent` |
| `providers/home_assistant/provider.py` | Create | Class with `connect`, `disconnect`, `subscribe`, `list_collections` (entity list), `get_available_variables`, `get_provider_config_schema`, `test_connection`. `poll` raises NotImplementedError. |
| `templates/defaults/en/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/ru/home_assistant_*.jinja2` | Create | 4 slot templates |
| `templates/defaults/loader.py` | Modify | Add to `PROVIDER_SLOT_FILE_MAP` |
| `templates/command_defaults/loader.py` | Modify | Stub entry — empty commands list for now |
| `templates/context.py` | Modify | HA context builder |
| `templates/validator.py` | Modify | Whitelist HA variable names |
**Server** (`packages/server/src/notify_bridge_server/`)
| Path | Action | Notes |
| --- | --- | --- |
| `services/watcher.py` *(or scheduler / lifecycle module that hosts polling)* | Modify | Add subscription-manager branch — for providers whose class overrides `subscribe`, start/stop long-running task instead of polling |
| `services/scheduler.py` | Verify | Confirm we cancel HA subscription on shutdown (graceful_shutdown_seconds path) |
| `api/template_configs.py` | Modify | `get_template_variables()` entry |
| `api/command_template_configs.py` | Modify | Sample ctx (minimal for Phase 1 — no commands) |
| `services/sample_context.py` | Modify | `_SAMPLE_CONTEXT["home_assistant"]` |
| `database/seeds.py` | Modify | Seed notification templates + default tracking config |
**Frontend** (`frontend/src/`)
| Path | Action | Notes |
| --- | --- | --- |
| `lib/providers/home-assistant.ts` | Create | Descriptor per CLAUDE.md rule 11 |
| `lib/providers/index.ts` | Modify | Register descriptor |
| `lib/locales/en.json` | Modify | `providers.typeHomeAssistant`, `gridDesc.providerHomeAssistant` |
| `lib/locales/ru.json` | Modify | Same |
**Tests**
| Path | Action |
| --- | --- |
| `packages/core/tests/providers/test_home_assistant_parser.py` | Create — HA payload → `ServiceEvent` |
| `packages/core/tests/providers/test_home_assistant_client.py` | Create — WS auth, subscribe, reconnect (use a fake server) |
| `packages/server/tests/test_home_assistant_subscription.py` | Create — subscription manager lifecycle, event flows through dispatcher |
### Frontend descriptor essentials
```text
type: "home_assistant"
defaultName: "Home Assistant"
icon: "home" (consider Lucide icon; HA logo if a custom asset exists)
hasUrl: true // base URL of HA (used to derive WS URL)
configFields:
- url: http(s)://homeassistant.local:8123
- access_token: long-lived access token (required)
- allowed_event_types: comma-separated, defaults to "state_changed"
eventFields: 4 checkboxes (state_changed, automation_triggered,
call_service, event_fired)
extraTrackingFields:
- entity_glob: tag input ("light.*", "binary_sensor.*_motion")
- domain_allowlist: tag input
collectionMeta: { label: "Entities", icon: "..." }
webhookBased: false // we are NOT webhook based
```
WS URL is derived: `wss://{host}/api/websocket` (or `ws://` for plain http
HA). Document this in the UI hint.
### Auth model
- **Long-lived access token** from HA (Profile → Long-Lived Access Tokens).
- Stored encrypted at rest via the same path the other providers use for
secrets (the bridge already has a secret-encryption helper — verify the
exact module name during implementation).
- WS auth handshake: connect → server sends `auth_required` → client sends
`{type: "auth", access_token: "..."}` → server replies `auth_ok` or
`auth_invalid`.
### Risks / open questions (Phase 1)
1. **Reconnect strategy.** Exponential backoff capped at 60s, jittered.
On reconnect, log a `connection_restored_after` event so the UI can
surface the gap. Document that HA does not support event replay.
2. **Area registry.** Pulling `area_id` for entities requires a separate
`config/area_registry/list` WS call. Decision needed: fetch once on
connect and cache, refetch on `area_registry_updated` event, or skip
`area` from the context entirely in v1. Recommendation: fetch on
connect, refetch on `area_registry_updated`, skip if it fails (best-effort).
3. **TLS verification for self-signed HA.** Homelab users often have
self-signed certs. Need a `verify_tls: bool` config field (default true)
and a clear warning when disabled. Same pattern as
`NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` for the SSRF case.
4. **Backpressure.** HA's `state_changed` can fire hundreds of events per
minute in a busy install. The subscription manager must drop or coalesce
if the dispatcher backlog grows beyond a threshold. Cheapest cut: a
bounded `asyncio.Queue` between WS receiver and dispatch — `put_nowait`
with overflow counter visible in the event log.
5. **Entity filter precedence.** Tracking-config has `collection_ids`
(entity_id list) and we want `entity_glob` + `domain_allowlist`. Decision:
if both `collection_ids` and globs are set, union them (any match passes).
Documented prominently in the tracker UI.
6. **Library choice.** `hass-client` is a Python WS client maintained by the
HA community; alternative is rolling our own with `websockets`. The
latter is ~150 LOC and has no external dependency surface. Recommendation:
roll our own. Re-evaluate if Phase 3 needs registry-aware service calls.
## Phase 2 — Bot Commands
Adds Telegram bot commands for HA tracking configs.
- `/status` — connection status, subscribed event count
- `/entities <glob>` — list matching entities + current state
- `/state <entity_id>` — full state + attributes for one entity
- `/areas` — area registry summary
- `/help`
These use the existing WS connection (no new client) via WS commands
`get_states`, `config/area_registry/list`. Template slots and command
template configs follow the same pattern as Gitea/Planka — see
[CLAUDE.md](../../CLAUDE.md) rule 7 / rule 11 for the full set of locations
that must be updated.
Out-of-scope for Phase 2: any command that mutates HA state.
## Phase 3 — Smart Actions (Service Calls)
A new action descriptor in the existing Smart Actions framework
([packages/core/src/notify_bridge_core/providers/actions.py](../../packages/core/src/notify_bridge_core/providers/actions.py)).
- Action type: `ha_call_service`
- Rule: trigger event → service call (e.g. "on motion event in
`binary_sensor.front_door` → call `light.turn_on` on `light.porch`")
- Executor uses the existing WS connection to send `call_service`.
This phase is gated behind explicit per-target authorization in the UI — HA
service calls can do anything the access token allows, including unlocking
doors. Default state: **disabled**, with a clear consent flow when enabling.
## Rough effort estimates
These are rough — sub-task discovery during Phase 1 will refine them.
| Phase | Estimate (focused work) |
| --- | --- |
| Phase 1 (subscribe + dispatch) | 23 sessions |
| Phase 2 (bot commands) | 1 session |
| Phase 3 (smart actions) | 12 sessions |
## When to start
Phase 1 work order, once you green-light it:
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
provider.
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
needed for these).
3. Subscription manager in `services/watcher.py` — integration test with the
fake provider from step 1.
4. Templates (en + ru), capabilities entry, validator whitelist.
5. Server: seeds, sample context, template_configs entry.
6. Frontend: descriptor, locale keys, i18n.
7. End-to-end smoke test against a real HA instance (homelab).
Backend restart cadence per the project rule: after **every** change in
`packages/server/` or `packages/core/`.
## Decision log
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
webhook for future-proofing toward Phases 2 + 3). Not started.
+27
View File
@@ -0,0 +1,27 @@
---
name: Debug Issue
description: Systematically debug issues using graph-powered code navigation
---
## Debug Issue
Use the knowledge graph to systematically trace and debug issues.
### Steps
1. Use `semantic_search_nodes` to find code related to the issue.
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
3. Use `get_flow` to see full execution paths through suspected areas.
4. Run `detect_changes` to check if recent changes caused the issue.
5. Use `get_impact_radius` on suspected files to see what else is affected.
### Tips
- Check both callers and callees to understand the full context.
- Look at affected flows to find the entry point that triggers the bug.
- Recent changes are the most common source of new issues.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Explore Codebase
description: Navigate and understand codebase structure using the knowledge graph
---
## Explore Codebase
Use the code-review-graph MCP tools to explore and understand the codebase.
### Steps
1. Run `list_graph_stats` to see overall codebase metrics.
2. Run `get_architecture_overview` for high-level community structure.
3. Use `list_communities` to find major modules, then `get_community` for details.
4. Use `semantic_search_nodes` to find specific functions or classes.
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
6. Use `list_flows` and `get_flow` to understand execution paths.
### Tips
- Start broad (stats, architecture) then narrow down to specific areas.
- Use `children_of` on a file to see all its functions and classes.
- Use `find_large_functions` to identify complex code.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+28
View File
@@ -0,0 +1,28 @@
---
name: Refactor Safely
description: Plan and execute safe refactoring using dependency analysis
---
## Refactor Safely
Use the knowledge graph to plan and execute refactoring with confidence.
### Steps
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
5. After changes, run `detect_changes` to verify the refactoring impact.
### Safety Checks
- Always preview before applying (rename mode gives you an edit list).
- Check `get_impact_radius` before major refactors.
- Use `get_affected_flows` to ensure no critical paths are broken.
- Run `find_large_functions` to identify decomposition targets.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+29
View File
@@ -0,0 +1,29 @@
---
name: Review Changes
description: Perform a structured code review using change detection and impact
---
## Review Changes
Perform a thorough, risk-aware code review using the knowledge graph.
### Steps
1. Run `detect_changes` to get risk-scored change analysis.
2. Run `get_affected_flows` to find impacted execution paths.
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
4. Run `get_impact_radius` to understand the blast radius.
5. For any untested changes, suggest specific test cases.
### Output Format
Provide findings grouped by risk level (high/medium/low) with:
- What changed and why it matters
- Test coverage status
- Suggested improvements
- Overall merge recommendation
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
+3 -3
View File
@@ -1,8 +1,8 @@
{
"last_commit": "a31b1cba2a41229f6f6af9701477d24d15efbe9a",
"last_sync": "2026-04-21T00:00:00Z",
"last_commit": "cfdafa9c2b49ea64496e9355d92337dbbb70db93",
"last_sync": "2026-05-16T00:00:00Z",
"tracked_files": {
"gitea-python-ci-cd.md": "sha256:61968058ec30cac954a3b7f9bde2a7db620618482d34e17568d432f680a3b333",
"gitea-python-ci-cd.md": "sha256:9f1f57e1b0d909143e20cb3f21ac9c4d75b45f2992ec002645540f94c4920851",
"gitea-release-workflow.md": "sha256:5eb64789fca062b2138ca7661b942c9fc9c304f63326844ff6f6724e7e05b08c"
}
}
+44 -2
View File
@@ -29,16 +29,58 @@ jobs:
- name: Svelte check
run: |
cd frontend
npm run check || echo "::warning::svelte-check reported warnings"
npm run check
- name: Build
run: |
cd frontend
npm run build
test-backend:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Editable installs of packages/core + packages/server are extremely slow
# on the hosted runner — measured 4-6x slower than building wheels first
# because hatchling's editable hook re-resolves on every collection. We
# build wheels once into an isolated venv, then install them (and only
# the test deps). The venv isolation also prevents broken-wheel installs
# from leaking dist-info across runs on the persistent Gitea runner
# (pip can't uninstall a wheel that landed without a RECORD file). The
# wheels themselves are NOT cached because their hashes depend on every
# file under packages/ — invalidates on basically every PR. Pip's HTTP
# cache for the test deps is enough.
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
build-image:
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
needs: [test-frontend]
needs: [test-frontend, test-backend]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+36
View File
@@ -10,7 +10,43 @@ env:
IMAGE_NAME: alexei.dolgolyov/notify-bridge
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Wheel-first strategy in an isolated venv — editable install is too slow,
# and a plain pip install into the toolcache Python leaks state across
# runs on the persistent Gitea runner (previous broken wheel installs
# leave dist-info dirs that pip can't uninstall: "uninstall-no-record-file").
- name: Build wheels in isolated venv
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --upgrade pip build
mkdir -p /tmp/wheels
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
- name: Run pytest
env:
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTIFY_BRIDGE_DEBUG: "false"
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
run: |
cd packages/server
/tmp/venv/bin/pytest tests --tb=short
release:
needs: [test-backend]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
+394
View File
@@ -0,0 +1,394 @@
# Operations Guide
This document covers running, monitoring, and recovering Notify Bridge in
production. The intended audience is the operator on call when the
notifications stop firing or when a release upgrade goes sideways.
For developer-focused docs (architecture, conventions, project layout) see
`CLAUDE.md` and the `.claude/docs/` directory.
## Deployment overview
Notify Bridge ships as a single Docker image. All state lives in a single
data directory mounted at `/data`.
### Required environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_SECRET_KEY` | _(none)_ | **Required.** 32+ random bytes. The server refuses to boot with the default placeholder or any of the known dev literals. |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | `http://localhost:5175` | Comma-separated list. `*` is rejected because credentials are enabled. |
| `NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse-proxy IP. |
### Useful environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `NOTIFY_BRIDGE_DATA_DIR` | `/data` | Where the SQLite DB, snapshots, and backups live. |
| `NOTIFY_BRIDGE_DATABASE_URL` | _(derived from data_dir)_ | Override only if you want a non-default DB path. |
| `NOTIFY_BRIDGE_DEBUG` | `false` | Verbose logging + SQL echo. Do not enable in production. |
| `NOTIFY_BRIDGE_LOG_FORMAT` | `text` | Set to `json` for one JSON object per line — pipe to a log aggregator. |
| `NOTIFY_BRIDGE_LOG_LEVEL` | `INFO` | Root logger level. |
| `NOTIFY_BRIDGE_LOG_LEVELS` | _(empty)_ | Per-module overrides, e.g. `sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG`. |
| `NOTIFY_BRIDGE_EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history kept by the daily cleanup job. `0` disables retention. |
| `NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots retained. `0` disables snapshotting. |
| `NOTIFY_BRIDGE_METRICS_ENABLED` | `true` | Expose `/api/metrics` for Prometheus. Set to `false` if the API port crosses a trust boundary. |
| `NOTIFY_BRIDGE_GRACEFUL_SHUTDOWN_SECONDS` | `60` | SIGTERM grace period before in-flight requests are killed. |
| `NOTIFY_BRIDGE_SUPERVISED` | _(auto)_ | Force the supervised flag for `apply-restart`. Use `true` when running under systemd/PM2 outside Docker. |
### Data directory layout
```
/data/
notify_bridge.db # main SQLite DB (WAL mode)
notify_bridge.db-wal # SQLite write-ahead log
notify_bridge.db-shm # SQLite shared memory file
backups/
pre-migrate-*.db # automatic pre-upgrade snapshots
backup-*.json # scheduled / manual config backups
snapshots/ # legacy alias retained for older deployments
pending_restore.json # staged restore (consumed at next boot)
applied_restores/ # archive of applied restore payloads
```
Always mount `/data` on a persistent volume. The WAL files MUST live on the
same filesystem as the main DB — never split them across mounts.
### Docker example
See `docker-compose.yml` at the repo root for the canonical reference. The
container runs read-only with `tmpfs` for `/tmp`, drops all capabilities,
and limits memory/CPU. The healthcheck targets `/api/ready` (deep) — see
the next section.
## Healthchecks
Two endpoints, used for different probe types.
### `GET /api/health` — liveness, shallow
Returns `200 OK` once the ASGI app has started. Does not touch the DB or
the scheduler. Use this for liveness probes that should only restart the
process if it stops responding entirely.
```json
{"status": "ok", "version": "0.8.0"}
```
### `GET /api/ready` — readiness, deep
Verifies that each critical dependency is reachable:
* **db** — `SELECT 1` against the SQLAlchemy engine, 2-second timeout.
* **scheduler** — APScheduler `running` flag.
* **ha** — Home Assistant subscription supervisor task. Reported as
`na` when no HA providers are configured, `ok` when at least one
supervisor is alive, `degraded` otherwise. **Informational only**
HA degradation does not flip readiness off.
Returns `503` when any required check (db, scheduler) fails.
```json
{
"ready": true,
"checks": {"db": "ok", "scheduler": "ok", "ha": "na"},
"errors": [],
"version": "0.8.0"
}
```
### Kubernetes probe example
```yaml
livenessProbe:
httpGet:
path: /api/health
port: 8420
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/ready
port: 8420
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 2
```
The Docker compose file uses `/api/ready` as its healthcheck so the
container is only reported healthy after migrations finish.
## Metrics
Notify Bridge exposes Prometheus metrics at `GET /api/metrics` in the
standard text exposition format. **No authentication** — Prometheus
scrapers do not authenticate. Disable via `NOTIFY_BRIDGE_METRICS_ENABLED=false`
when the API port is reachable beyond the trust boundary.
### Prometheus scrape example
```yaml
scrape_configs:
- job_name: notify-bridge
metrics_path: /api/metrics
static_configs:
- targets: ['notify-bridge.internal:8420']
scrape_interval: 30s
```
### Available metrics
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `notify_bridge_deferred_pending` | Gauge | _(none)_ | Pending rows in `deferred_dispatch`. Refreshed on each scrape. A persistent non-zero value usually means a tracker target is in extended quiet hours. |
| `notify_bridge_event_log_total` | Counter | `status`, `event_type` | Events written to `event_log`. `status` is the dispatch outcome (`dispatched`, `dropped`, `deferred`, etc.). |
| `notify_bridge_dispatch_duration_seconds` | Histogram | `channel` | Wall-clock duration of one outbound dispatch (Telegram, Discord, email, …). Useful for latency alerts. |
| `notify_bridge_provider_poll_failures_total` | Counter | `provider_type` | Polling provider tick failures (Immich poll error, Gitea API down, …). Compare against expected scan interval to compute failure rate. |
| `notify_bridge_target_send_failures_total` | Counter | `target_type`, `status_code` | Failed sends to a notification channel. `status_code` is the HTTP status (or `0` when no HTTP response was received). |
The metrics module never imports `prometheus_client` outside `api/metrics.py`.
Other modules record events through the `metrics` singleton — see that
module's docstring before adding new collectors.
## Backups
Notify Bridge produces three different kinds of backup files. Know which
one you are looking at before restoring.
| Kind | Location | Format | Trigger |
| --- | --- | --- | --- |
| Config backup | `data/backups/backup-*.json` | JSON (BackupFile schema) | Manual via `/api/backup/files` POST or scheduled job |
| Pre-migration snapshot | `data/backups/pre-migrate-*.db` | SQLite DB file | Automatic on every boot before migrations |
| Pending restore | `data/pending_restore.json` | JSON | Staged via `/api/backup/prepare-restore`, consumed at next restart |
Config backups capture user configuration (providers, trackers, targets,
templates, …). They do **not** include `event_log`, `deferred_dispatch`,
or any other operational table. Pre-migration snapshots are full DB
copies and contain everything.
### Manual backup
The admin UI has a one-click button under Settings → Backup. Equivalent
HTTP call:
```bash
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude"
```
The download endpoint produces a downloadable JSON envelope with no
secrets unless `secrets_mode=include` is passed:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
-OJ "https://notify-bridge.example.com/api/backup/export?secrets_mode=exclude"
```
### Scheduled backup
Configure under Settings → Backup or via `PUT /api/backup/scheduled` with:
```json
{
"backup_scheduled_enabled": "true",
"backup_scheduled_interval_hours": "24",
"backup_secrets_mode": "exclude",
"backup_retention_count": "5"
}
```
Saved files land in `data/backups/`; retention prunes the oldest files
beyond `backup_retention_count`. Backups can be downloaded individually:
```bash
curl -fsS -X GET \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/files/backup-2026-05-16T12-00-00.json" \
-o backup-latest.json
```
### Cron snippet for off-host backup
```bash
# /etc/cron.d/notify-bridge-backup
0 3 * * * www-data \
curl -fsS -X POST \
-H "Authorization: Bearer $(cat /etc/notify-bridge/admin.token)" \
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude" \
-o /var/backups/notify-bridge/backup-$(date +\%F).json
```
### Restore procedure
Restoring REPLACES configuration. Always export the current state first.
```bash
# 1. Stage the backup file (validates and writes to data/pending_restore.json)
curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_JWT" \
-F "file=@backup-2026-05-16T12-00-00.json" \
"https://notify-bridge.example.com/api/backup/prepare-restore?conflict_mode=overwrite"
# 2. Trigger graceful restart so startup applies the staged restore.
# Same-origin Origin/Referer is enforced — call from the admin UI when
# possible, or from the same host. Requires the supervisor to respawn
# the process (Docker restart policy, systemd, PM2, etc.).
curl -fsS -X POST \
-H "Origin: https://notify-bridge.example.com" \
-H "Referer: https://notify-bridge.example.com/settings/backup" \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/apply-restart"
```
If the process is **not** supervised, `/api/backup/apply-restart` returns
`409`. Restart the backend manually after staging — startup applies the
pending restore on the next boot.
To cancel a staged restore before applying:
```bash
curl -fsS -X DELETE \
-H "Authorization: Bearer $ADMIN_JWT" \
"https://notify-bridge.example.com/api/backup/pending-restore"
```
### Recovery from a corrupted DB
If migrations crash on boot or the DB file is unreadable, roll back to a
pre-migration snapshot:
```bash
# Stop the backend, then
cd /var/lib/docker/volumes/notify-bridge-data/_data
ls -1t backups/pre-migrate-*.db | head -5 # pick the snapshot
cp notify_bridge.db notify_bridge.db.broken # keep the broken DB for forensics
cp backups/pre-migrate-2026-05-16T11-58-30.db notify_bridge.db
rm -f notify_bridge.db-wal notify_bridge.db-shm # WAL belongs to the broken file
```
Restart the container. The startup snapshot will run again and capture
the rolled-back state, so you have a clean recovery point if the next
boot needs another rollback.
## Logs
* Output goes to **stderr only**. The Docker log driver captures it.
* Set `NOTIFY_BRIDGE_LOG_FORMAT=json` for line-delimited JSON suitable
for Loki, ELK, or CloudWatch.
* Secret values (bot tokens, API keys, passwords) are masked at the log
formatter level — see `notify_bridge_server.logging_setup`.
* No file rotation is built in. Use the Docker JSON log driver's
`max-size`/`max-file` options or send logs to your aggregator.
```yaml
# docker-compose.yml snippet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
```
## Common operational scenarios
### "Notifications stopped firing"
1. Hit `/api/ready`. If `scheduler` is `fail`, restart the backend; the
scheduler died in a way it cannot recover from.
2. Check `notify_bridge_deferred_pending`. A non-zero value during quiet
hours is normal; a value that grows monotonically across days is a
bug — inspect the `deferred_dispatch` table.
3. Inspect the most recent `event_log` rows in the admin Events page or:
```sql
SELECT created_at, event_type, dispatch_status, details
FROM event_log
ORDER BY created_at DESC LIMIT 50;
```
Look for a `dispatch_status` other than `dispatched`.
4. If a single tracker is silent, verify the provider's last poll status
in the admin UI (Providers page) — `notify_bridge_provider_poll_failures_total`
tells you which provider type is failing.
5. If you've configured a `bridge_self` tracker but never received a
self-monitoring alert when something failed, see the next section —
`bridge_self` failures are deliberately log-only to prevent recursion.
### Bridge self-monitoring is log-only on its own failures
The built-in `bridge_self` provider emits notifications when polls,
dispatches, or target sends fail. To prevent infinite-recursion (a
`bridge_self` notification failing → triggering another `bridge_self`
notification → ...), failures of `bridge_self` events themselves are
**not** counted toward target-failure thresholds and are logged only.
If your `bridge_self` notifications stop arriving, it means the
notification target you wired them to is itself failing. Grep stderr for:
```text
bridge_self target-failure emission failed
emit_bridge_self_event failed
```
The fix is always at the target layer (Telegram bot blocked, Matrix
homeserver down, SMTP credentials rotated). The bridge cannot tell you
about its own outbound failure — that's what the operator's external
monitoring (Prometheus alert on `notify_bridge_target_send_failures_total`)
is for.
### "Webhook returns 500"
Inspect the `webhook_payload_log` table for the matching request:
```sql
SELECT received_at, status_code, error_message, payload_excerpt
FROM webhook_payload_log
ORDER BY received_at DESC LIMIT 20;
```
Common causes: payload schema change in the source service, a tracker
referencing a deleted provider, a Jinja template that errors out (look
for `template render failed` in logs).
### "Telegram bot rate-limited (429)"
The Telegram client implements exponential backoff with jitter on
`Retry-After`. No operator action is required for transient throttling.
If the rate-limit persists, check:
* The bot is being driven by multiple Notify Bridge instances pointing
at the same chat (split-brain — only one instance should own a bot).
* A template is producing very large messages (Telegram limits message
size to 4096 chars). Look for `MessageTooLong` in the logs.
### "DB lock contention"
SQLite WAL mode and `busy_timeout=10000` make this rare. If you see
`SQLITE_BUSY` in logs:
* Check for long-running transactions (most often a stuck migration).
* Confirm the WAL files are on the same filesystem as the main DB —
splitting them across mounts is a known cause.
* Run `sqlite3 notify_bridge.db "PRAGMA wal_checkpoint(TRUNCATE);"` to
flush the WAL. Safe to run while the backend is up.
## Upgrades
1. Pre-migration snapshot is taken automatically before any migration
runs. The latest five snapshots are retained by default.
2. Migrations are idempotent — re-running an upgrade is safe.
3. If a migration fails, the snapshot from step 1 is the recovery point.
See "Recovery from a corrupted DB" above.
4. Always test major version upgrades in staging first. The upgrade flow
is the same in staging: pull the new image, restart the container.
The release tag stream lives at the project Gitea / GitHub releases page.
Release notes are written to `RELEASE_NOTES.md` for the upcoming version
and copied into the Gitea release body by the `release.yml` workflow.
+142 -11
View File
@@ -2,20 +2,21 @@
A generic bridge between service providers and notification targets.
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
notifications to configurable targets (Telegram, webhooks) using customizable templates.
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
and internal scheduler) for changes and dispatches notifications to configurable targets
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
## Architecture
- **Service Providers** — Connectors to external services (Immich, more coming)
- **Service Providers** — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
- **Trackers** — Monitor specific collections within a provider for changes
- **Tracking Configs** — Define what events to watch for and scheduling rules
- **Notification Targets** — Where to send notifications (Telegram chats, webhook URLs)
- **Notification Targets** — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
- **Template Configs** — Jinja2 templates that format notifications per provider type
## Project Structure
```
```text
packages/
core/ — Shared library: providers, models, notifications, templates
server/ — FastAPI REST server with SQLite database
@@ -31,6 +32,7 @@ docker run -d \
-p 8420:8420 \
-v notify-bridge-data:/data \
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
```
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
### Environment Variables
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
| Variable | Required | Default | Description |
| -------- | -------- | ------- | ----------- |
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
| `CORS_ALLOWED_ORIGINS` | Recommended | `http://localhost:5175` | Comma-separated browser origins. Wildcard `*` is **rejected** because credentials are enabled. Set this to the URL you load the UI from. |
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
| `DATABASE_URL` | No | `sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db` | Override DB connection string. |
| `HOST` | No | `0.0.0.0` | Bind address. |
| `PORT` | No | `8420` | Server listen port. |
| `DEBUG` | No | `false` | Enable debug logging. |
Reverse proxy / network:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse proxy IP (e.g. `172.17.0.1` for the default Docker bridge). Use `*` only when the container is not directly internet-reachable. |
| `EXTERNAL_URL` | — | Public base URL (e.g. `https://notify.example.com`). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
| `ALLOW_PRIVATE_URLS` | unset | Set to `1` to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). **Do not enable on a publicly exposed instance.** |
Auth & tokens:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Lifetime of access JWTs. |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `30` | Lifetime of refresh tokens. |
| `JWT_ISSUER` | `notify-bridge` | `iss` claim. |
| `JWT_AUDIENCE` | `notify-bridge-api` | `aud` claim. |
Logging (all are also live-editable in the Settings page, except `log_format`):
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `LOG_LEVEL` | `INFO` | Root level: `DEBUG` / `INFO` / `WARNING` / `ERROR`. |
| `LOG_FORMAT` | `text` | `text` or `json`. Switching requires a restart. |
| `LOG_LEVELS` | — | Per-module overrides, e.g. `notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO`. |
Retention & maintenance:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history to keep. `0` disables the retention job. |
| `PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots to keep in `<DATA_DIR>/backups/`. `0` disables snapshotting. |
| `GRACEFUL_SHUTDOWN_SECONDS` | `60` | Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
Integrations & misc:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `TELEGRAM_WEBHOOK_SECRET` | — | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
| `TIMEZONE` | `UTC` | IANA timezone (e.g. `Europe/Warsaw`) used by the scheduler. Also settable from the Settings page. |
| `STATIC_DIR` | `/app/static` (in Docker) | Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
| `SUPERVISED` | auto-detect | Set to `1` to tell the backup endpoint that an external supervisor will restart the process. |
### Docker Compose
@@ -58,12 +107,50 @@ services:
volumes:
- notify-bridge-data:/data
environment:
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
# Comma-separated list of allowed browser origins. Wildcard `*` is
# rejected on startup because credentials are enabled.
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
# docker bridge, or `*` only if the container is NOT reachable from the
# public internet).
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
# enable on a publicly exposed instance.
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
# Use /api/ready (not /api/health) so the container is only reported
# healthy after migrations and the scheduler finish booting.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
cpus: 1.0
pids_limit: 256
volumes:
notify-bridge-data:
```
A ready-to-use `docker-compose.yml` lives at the repo root.
### Health & Readiness
- `GET /api/health` — process is up. Use for liveness probes.
- `GET /api/ready` — migrations + scheduler have booted. Use for readiness probes and Docker `HEALTHCHECK` (as the compose example above does).
## Quick Start (Development)
```bash
@@ -81,4 +168,48 @@ npm run dev
## Supported Providers
- **Immich** — Photo/video server with album change detection
- **Immich** — Photo/video server with album change detection (polling)
- **Gitea** — Git server with push / issue / PR / release events (webhook)
- **Planka** — Kanban board with card / list / board events (webhook)
- **NUT** — Network UPS Tools for battery / power events (polling)
- **Google Photos** — Album change detection (polling)
- **Generic Webhook** — Catch arbitrary JSON payloads and route them via templates (webhook)
- **Scheduler** — Internal provider for time-based scheduled messages
## Supported Notification Targets
- **Telegram** — Bot API with rich formatting, media groups, and inline commands
- **Discord** — Webhook-based delivery with embeds
- **Slack** — Incoming webhooks with Block Kit formatting
- **Matrix** — Homeserver delivery with HTML formatting
- **ntfy** — Self-hostable push notifications
- **Email** — SMTP with HTML / plain-text templates
- **Generic Webhook** — POST custom JSON payloads to any URL
## Bot Commands
Telegram bots can serve interactive commands per provider. All commands use
Jinja2 templates that you can customize from the **Command Templates** page.
| Provider | Commands |
| -------- | -------- |
| Immich | `/status` `/albums` `/events` `/summary` `/latest` `/memory` `/random` `/search` `/find` `/person` `/place` `/favorites` `/people` `/help` |
| Gitea | `/status` `/repos` `/issues` `/prs` `/commits` `/help` |
| Planka | `/status` `/boards` `/cards` `/lists` `/help` |
| NUT | `/status` `/devices` `/battery` `/help` |
| Google Photos | `/status` `/albums` `/latest` `/search` `/random` `/help` |
| Generic Webhook | `/status` `/help` |
Every provider also responds to `/start`, and rate-limit / empty-result
fallback messages are templated as well.
## Smart Actions
Beyond notifications, providers can run **actions** against the source service.
Currently implemented:
- **Immich — Auto-Organize** — Automatically sort newly-detected assets into
albums based on configurable rules. Each rule combines criteria (people in
the photo, search query, favorites, date range) with a target album, and can
create the album if it doesn't exist. Supports dry-run mode for previewing
what would move before committing.
+44 -13
View File
@@ -1,25 +1,56 @@
# v0.7.2 (2026-05-11)
# v0.8.2 (2026-05-22)
## Features
A production-readiness hardening release that follows up on v0.8.1 with six isolated, low-risk fixes surfaced by a parallel full-codebase review (backend, frontend, security, performance, UI/UX, bugs+features). No breaking changes; no migrations required.
- Redesign settings/common with Aurora cassettes — refreshed identity, logging, Telegram, and cache-ledger sections with the new glass/cassette UI ([6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9))
- Group targets by bot in the targets view and redesign backup settings ([a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad))
- Add `/status` command handler for webhook providers ([bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928))
## User-facing changes
## Bug Fixes
### Security
- Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
- **Provider `access_token` masked in API responses.** The provider GET endpoints were leaking plaintext credentials — most importantly Home Assistant long-lived tokens — in their JSON payloads. The field is now masked on read and dropped on edit when the `***` placeholder is sent back, so the UI can show "set" / "unset" without ever round-tripping the secret. Centralized through `PROVIDER_SECRET_FIELDS` so every call site stays in sync ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
- **Pre-auth resource-exhaustion amplifier closed on webhook ingest.** The Gitea provider used to read the 1 MiB request body before checking whether a secret was even configured or whether the request had a signature header — an unauthenticated client could force a body read on every hit. The generic-webhook bearer-token path had the same shape: body read before Authorization check. Both now bail out before consuming the body when the auth precondition fails ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
### Bug Fixes
- **Home Assistant status-change events no longer silently lost.** `ha_status_changed` rows are written from `asyncio.create_task(...)`, but `create_task` only keeps a weak reference — the task was being garbage-collected before the row landed, so connection-flap events disappeared. The task handles are now held in a module-level set with a `done_callback` to release them on completion ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
- **Telegram-webhook handler exceptions can no longer leak writes.** The catch-all error path in the Telegram inbound endpoint now rolls back the request's SQLAlchemy session before returning, so a handler crash mid-transaction cannot bleed uncommitted state into the next request on the same connection ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
### Accessibility
- **Toast notifications now announced by screen readers.** Added `role="region"` on the snackbar container plus per-toast `role` / `aria-live` / `aria-atomic` attributes, with a localized region name (`snackbar.region`) in both `en` and `ru` ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
- **Active sidebar link now has an accessible state.** `aria-current="page"` is now set on the matching nav item, so assistive tech can announce the active route ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
---
## Development / Internal
### Refactoring
- **Last `provider.type === 'immich'` check removed from components.** The action-rule editor's "Auto-organize" affordance now consumes a `supportsAutoOrganize` capability on `ProviderDescriptor` instead of branching on the provider type — bringing the rule editor under CLAUDE.md rule 8 (no provider-specific hardcoding in components) ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
### Chores
- **Synced `.facts-sync.json` with `claude-code-facts@cfdafa9`.** Both previously pending suggestions (venv install for monorepos + hatchling METADATA workaround) were applied upstream; the local queue is empty ([a20635a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a20635a))
---
### Known gaps (tracked for follow-up)
The full-codebase review surfaced more ship-blockers than this release fixes. Each of the items below needs more than a mechanical edit and is tracked in `.claude/reviews/README.md`:
- Secret encryption at rest
- JWT moved into an HTTP-only cookie
- Alembic adoption (currently `create_all`)
- Webhook delivery idempotency
- Deferred-dispatch crash window
- Persisted Telegram update watermark
- `bridge_self` counter lock
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
| [a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad) | feat(frontend): group targets by bot, redesign backup settings | alexei.dolgolyov |
| [bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928) | feat(server): add /status command handler for webhook providers | alexei.dolgolyov |
| [87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c) | fix(frontend): stop event-log flicker on pagination | alexei.dolgolyov |
- [2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b) — `fix: production-readiness hardening from full-codebase review` (alexei.dolgolyov)
- [a20635a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a20635a) — `chore: sync .facts-sync.json with claude-code-facts@cfdafa9` (alexei.dolgolyov)
</details>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.7.1",
"version": "0.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.7.1",
"version": "0.8.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.7.2",
"version": "0.8.2",
"type": "module",
"scripts": {
"dev": "vite dev",
+89
View File
@@ -377,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; }
@@ -465,3 +505,52 @@ button:focus-visible, a:focus-visible {
scroll-behavior: auto !important;
}
}
/* Shared toggle switch — used by provider config forms, tracking-config
extraTrackingFields, and anywhere else we render a boolean field.
Kept global so adding a new ConfigField type='toggle' caller doesn't
need to copy the CSS into its scoped <style>. */
.toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
height: 1.75rem;
}
.toggle-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch .toggle-track {
position: relative;
width: 2.5rem;
height: 1.375rem;
background: var(--color-border);
border-radius: 9999px;
transition: background 0.2s ease;
}
.toggle-switch .toggle-track::after {
content: '';
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1rem;
height: 1rem;
background: var(--color-foreground);
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle-switch input:checked + .toggle-track::after {
transform: translateX(1.125rem);
background: var(--color-primary-foreground);
}
+40 -8
View File
@@ -2,8 +2,41 @@
* API client with JWT auth for the Notify Bridge backend.
*/
import { goto } from '$app/navigation';
const API_BASE = '/api';
/**
* Thrown when the API client decides to redirect the user to /login (after a
* terminal 401). Caller-side `try/catch` blocks can branch on
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
* — the redirect itself is the user-visible signal.
*/
export class AuthRedirectError extends Error {
constructor() {
super('Unauthorized — redirecting to login');
this.name = 'AuthRedirectError';
}
}
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
// the dashboard's parallel cache loads) should only schedule a single
// `goto('/login')` instead of stacking N navigations.
let _redirecting = false;
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
function redirectToLogin(): void {
if (_redirecting) return;
_redirecting = true;
clearTokens();
if (typeof window !== 'undefined') {
// SvelteKit's goto() with replaceState avoids leaving the failed page
// in the back-stack (no "back-button to broken view" UX). We don't
// reset `_redirecting` — the page about to unmount makes it moot.
goto('/login', { replaceState: true });
}
}
/** Normalize a caught error to a user-safe message. */
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
if (err instanceof Error && err.message) return err.message;
@@ -129,11 +162,11 @@ export async function api<T = any>(
}
if (res.status === 401 && token) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
redirectToLogin();
// Tagged so the caller's catch can distinguish "we already showed
// the user a redirect" from a real authorization failure they
// should snackbar.
throw new AuthRedirectError();
}
if (res.status === 204) return undefined as T;
@@ -204,9 +237,8 @@ export async function fetchAuth(
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
redirectToLogin();
throw new AuthRedirectError();
}
if (!res.ok) {
@@ -12,6 +12,13 @@
}
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);
@@ -21,6 +28,44 @@
}
}
/** 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;
@@ -41,47 +86,130 @@
goto(path);
}
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
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(event?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!event?.details) return '';
if (!displayEvent?.details) return '';
try {
return JSON.stringify(event.details, null, 2);
return JSON.stringify(displayEvent.details, null, 2);
} catch {
return String(event.details);
return String(displayEvent.details);
}
});
</script>
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
{#if event}
<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">{event.collection_name || event.event_type}</div>
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
<div class="hero-meta">
<span class="event-type">{event.event_type}</span>
<span class="event-type">{displayEvent.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(event.created_at)}</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 event.bot_name}
{#if displayEvent.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{event.bot_name}</dd>
<dd>{displayEvent.bot_name}</dd>
{/if}
{#if event.collection_id && isCommand}
{#if displayEvent.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{event.collection_id}</dd>
<dd class="font-mono">{displayEvent.collection_id}</dd>
{/if}
{#if issuerText}
<dt>{t('events.issuer')}</dt>
@@ -90,56 +218,64 @@
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
</dd>
{/if}
{#if event.command_tracker_name}
{#if displayEvent.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{event.command_tracker_name}</dd>
<dd>{displayEvent.command_tracker_name}</dd>
{/if}
{#if event.tracker_name}
{#if displayEvent.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{event.tracker_name}</dd>
<dd>{displayEvent.tracker_name}</dd>
{/if}
{#if event.action_name}
{#if displayEvent.action_name}
<dt>{t('events.action')}</dt>
<dd>{event.action_name}</dd>
<dd>{displayEvent.action_name}</dd>
{/if}
{#if event.provider_name}
{#if displayEvent.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{event.provider_name}</dd>
<dd>{displayEvent.provider_name}</dd>
{/if}
{#if event.assets_count > 0}
{#if displayEvent.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{event.assets_count}</dd>
<dd class="font-mono">{displayEvent.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<!-- Action buttons — deep-link + highlight the related entity card.
IDs are snapshotted into local consts so the deferred onclick
closures don't lose the narrowed type that the `{#if ...}` gate
proves at template-render time. -->
<div class="actions">
{#if event.provider_id}
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
{#if displayEvent.provider_id}
{@const providerId = displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', providerId)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if event.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
{#if displayEvent.telegram_bot_id && isCommand}
{@const botId = displayEvent.telegram_bot_id}
<button type="button" onclick={() => openEntity('/bots', botId)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if event.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
{#if displayEvent.command_tracker_id && isCommand}
{@const cmdTrackerId = displayEvent.command_tracker_id}
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if event.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
{#if displayEvent.action_id && isAction}
{@const actionId = displayEvent.action_id}
<button type="button" onclick={() => openEntity('/actions', actionId)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && event.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
{#if !isCommand && !isAction && displayEvent.tracker_id}
{@const trackerId = displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
@@ -251,4 +387,71 @@
word-break: break-word;
}
.font-mono { font-family: var(--font-mono); }
/* Dispatch lifecycle banner — appears only when the event took the
* quiet-hours defer path. The three colour variants mirror the dashboard
* badge palette: primary glow for "held", success for "delivered late",
* muted/dim for "dropped" / "failed" / "suppressed".
*/
.lifecycle {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.75rem 0.95rem;
border-radius: 0.7rem;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.lifecycle-body {
display: flex; flex-direction: column; gap: 0.2rem;
flex: 1; min-width: 0;
}
.lifecycle-title {
font-weight: 600;
color: var(--color-foreground);
}
.lifecycle-detail {
color: var(--color-foreground);
}
.lifecycle-detail b {
font-family: var(--font-mono);
font-weight: 600;
}
.lifecycle-rel {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-left: 0.25rem;
}
.lifecycle-hint {
color: var(--color-muted-foreground);
font-size: 0.72rem;
}
.lifecycle-reason {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
word-break: break-all;
}
.lifecycle--deferred {
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.lifecycle--deferred :global(svg) {
color: var(--color-primary);
}
.lifecycle--late {
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
}
.lifecycle--late :global(svg) {
color: var(--color-success, #16a34a);
}
.lifecycle--dropped {
opacity: 0.92;
}
.lifecycle--dropped :global(svg) {
color: var(--color-muted-foreground);
}
</style>
@@ -17,6 +17,7 @@
columns = 2,
disabled = false,
compact = false,
onChange,
}: {
items: GridItem[];
value: string | number | null;
@@ -24,6 +25,13 @@
columns?: number;
disabled?: boolean;
compact?: boolean;
/**
* Optional one-way change callback. Fired in addition to updating
* `value` so callers that own state externally (e.g. a global store)
* can avoid the read-modify-write feedback loop that `bind:value` plus
* a sync `$effect` produces.
*/
onChange?: (value: string | number) => void;
} = $props();
let open = $state(false);
@@ -63,6 +71,7 @@
value = item.value;
open = false;
search = '';
onChange?.(item.value);
}
function handleKeydown(e: KeyboardEvent) {
@@ -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"
+4 -1
View File
@@ -32,12 +32,15 @@
</script>
{#if snacks.length > 0}
<div use:portal class="snackbar-container">
<div use:portal class="snackbar-container" role="region" aria-label={t('snackbar.region')}>
{#each snacks as snack (snack.id)}
<div
in:fly={{ y: 40, duration: 300 }}
out:fade={{ duration: 200 }}
class="snack-item"
role={snack.type === 'error' ? 'alert' : 'status'}
aria-live={snack.type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
style="--snack-accent: {accentMap[snack.type]};"
>
<span class="snack-icon" style="color: {accentMap[snack.type]};">
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
/**
* Free-text chip input. Bind a string[] of values; commit a new chip on
* Enter, comma, or blur. Backspace on empty input deletes the last chip
* for parity with native chip-input UX.
*
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
* free-text filter keys like Home Assistant's entity_glob and
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
* picker dropdown sourced from an enumerable list.
*/
import MdiIcon from './MdiIcon.svelte';
interface Props {
values: string[];
onchange: (values: string[]) => void;
placeholder?: string;
icon?: string;
/** Strip / reject anything matching this regex on each entry. */
sanitize?: (raw: string) => string | null;
}
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
let draft = $state('');
function addRaw(raw: string): void {
const trimmed = raw.trim();
if (!trimmed) return;
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
if (!cleaned) return;
if (values.includes(cleaned)) return;
onchange([...values, cleaned]);
}
function commitDraft(): void {
if (!draft.trim()) return;
// Allow comma-separated paste — split on commas and add each.
for (const piece of draft.split(',')) {
addRaw(piece);
}
draft = '';
}
function removeAt(index: number): void {
onchange(values.filter((_, i) => i !== index));
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
e.preventDefault();
removeAt(values.length - 1);
}
}
</script>
<div class="tag-input">
{#each values as value, i (`${i}-${value}`)}
<span class="tag-chip">
{#if icon}<MdiIcon name={icon} size={12} />{/if}
<span class="tag-text">{value}</span>
<button
type="button"
aria-label="Remove"
class="tag-remove"
onclick={() => removeAt(i)}
>×</button>
</span>
{/each}
<input
type="text"
bind:value={draft}
onkeydown={onKey}
onblur={commitDraft}
placeholder={values.length === 0 ? placeholder : ''}
class="tag-draft"
autocomplete="off"
spellcheck="false"
/>
</div>
<style>
.tag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
min-height: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-background);
cursor: text;
}
.tag-input:focus-within {
border-color: var(--color-primary);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
background: var(--color-muted);
border-radius: 9999px;
font-size: 0.75rem;
line-height: 1;
color: var(--color-foreground);
}
.tag-text {
font-family: var(--font-mono, monospace);
}
.tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
font-size: 0.875rem;
line-height: 1;
background: transparent;
border: none;
color: var(--color-muted-foreground);
cursor: pointer;
border-radius: 9999px;
}
.tag-remove:hover {
background: var(--color-border);
color: var(--color-foreground);
}
.tag-draft {
flex: 1;
min-width: 8rem;
border: none;
outline: none;
background: transparent;
font-size: 0.875rem;
color: var(--color-foreground);
padding: 0.125rem 0;
}
.tag-draft::placeholder {
color: var(--color-muted-foreground);
}
</style>
+24 -1
View File
@@ -120,6 +120,16 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
];
// --- Provider stats scope (dashboard "On watch" deck) ---
//
// Toggles whether the provider deck stats reflect only the events visible
// on the current page or aggregate across all events matching the filters.
export const providerStatsModeItems = (): GridItem[] => [
{ value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') },
{ value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') },
];
// --- Auto-refresh interval (dashboard events list) ---
//
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
@@ -175,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
...allDescriptors().map(descriptorToGridItem),
];
/** Provider types the user is allowed to create from the "new provider" wizard.
*
* Excludes ``bridge_self`` because it's auto-created exactly once per user
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
* wizard would either duplicate the row or surface a confusing 409.
*/
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
allDescriptors()
.filter((d) => d.type !== 'bridge_self')
.map((d) => d.type);
/** Provider type selector (no "All" option). */
export const providerTypeItems = (): GridItem[] =>
allDescriptors().map(descriptorToGridItem);
allDescriptors()
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
.map(descriptorToGridItem);
+131 -4
View File
@@ -124,6 +124,15 @@
"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",
@@ -148,6 +157,9 @@
"eventsLabel": "events",
"onWatchTitle": "On",
"onWatchEmphasis": "watch",
"statsModeTitle": "Provider deck stats scope",
"statsModePage": "Page",
"statsModeAll": "All",
"noProviders": "No providers yet.",
"addProvider": "Add provider",
"addProviderHint": "Connect a service to start tracking",
@@ -179,7 +191,21 @@
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details"
"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",
@@ -209,6 +235,20 @@
"typeNut": "NUT (UPS)",
"typeGooglePhotos": "Google Photos",
"typeWebhook": "Generic Webhook",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Bridge Self-Monitoring",
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
"bridgeSelfTargetThreshold": "Target send failure threshold",
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
"haAccessToken": "Long-Lived Access Token",
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
"haAccessTokenRequired": "Home Assistant access token is required.",
"haVerifyTls": "Verify TLS certificate",
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -294,6 +334,13 @@
"selectBoards": "Select boards...",
"upsDevices": "UPS Devices",
"selectUpsDevices": "Select UPS devices...",
"entities": "Entities",
"selectEntities": "Select entities...",
"entities_count": "entity(ies)",
"haEntityGlob": "Entity glob filter",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Domain allowlist",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Event Types",
"notificationTargets": "Notification Targets",
"scanInterval": "Scan Interval (seconds)",
@@ -474,6 +521,7 @@
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"you": "you",
"addUser": "Add User",
"cancel": "Cancel",
"username": "Username",
@@ -617,6 +665,14 @@
"upsOverload": "UPS overloaded",
"scheduledMessage": "Scheduled message",
"webhookReceived": "Webhook received",
"haStateChanged": "Entity state changed",
"haAutomationTriggered": "Automation triggered",
"haServiceCalled": "Service called",
"haEventFired": "Other HA event (catch-all)",
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
"bridgeSelfPollFailures": "Tracker poll failures",
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
"bridgeSelfTargetFailures": "Target send failures",
"trackImages": "Track images",
"trackVideos": "Track videos",
"favoritesOnly": "Favorites only",
@@ -870,7 +926,58 @@
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes"
"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.",
@@ -1020,9 +1127,22 @@
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"commands": {
"bridgeSelf": {
"status": "Bridge status",
"statusDesc": "Show current bridge health counters",
"thresholds": "Bridge thresholds",
"thresholdsDesc": "Show configured alert thresholds",
"reset": "Reset counter",
"resetDesc": "Manually reset a failure counter",
"health": "Bridge health",
"healthDesc": "Terse one-line health summary"
}
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
"hideDetails": "Hide details",
"region": "Notifications"
},
"timezone": {
"searchPlaceholder": "Search cities or IANA codes…",
@@ -1031,6 +1151,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)…",
@@ -1100,6 +1222,7 @@
},
"common": {
"loading": "Loading...",
"auto": "Auto",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@@ -1241,6 +1364,8 @@
"refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes",
"statsModePage": "Count only events on the current page",
"statsModeAll": "Count all events matching the current filters",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1263,7 +1388,9 @@
"providerScheduler": "Time-based scheduled messages",
"providerNut": "Network UPS monitoring",
"providerGooglePhotos": "Google Photos albums & shared libraries",
"providerWebhook": "Receive events via HTTP POST"
"providerWebhook": "Receive events via HTTP POST",
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
},
"webhookLogs": {
"title": "Recent Payloads",
+131 -4
View File
@@ -124,6 +124,15 @@
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"heldUntil": "ожидает до",
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
"deliveredLate": "доставлено позже",
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
"deferredThenDropped": "отброшено после задержки",
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
"deferredThenFailed": "ошибка после задержки",
"suppressedQuietHours": "подавлено (тихие часы)",
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
"asset": "файл",
"assets": "файлов",
"eventActivity": "Активность событий",
@@ -148,6 +157,9 @@
"eventsLabel": "событий",
"onWatchTitle": "На",
"onWatchEmphasis": "слежении",
"statsModeTitle": "Область статистики провайдеров",
"statsModePage": "Страница",
"statsModeAll": "Все",
"noProviders": "Пока нет провайдеров.",
"addProvider": "Добавить",
"addProviderHint": "Подключите сервис, чтобы начать слежение",
@@ -179,7 +191,21 @@
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
"rawDetails": "Сырые данные",
"lifecycle": {
"heldTitle": "Задержано тихими часами",
"heldUntil": "Будет отправлено в",
"heldFor": "Задержано на",
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
"inPrefix": "через",
"deliveredLateTitle": "Доставлено после тихих часов",
"originalEvent": "Исходное событие",
"droppedTitle": "Отброшено после задержки",
"failedTitle": "Ошибка после задержки",
"reason": "Причина",
"suppressedTitle": "Подавлено тихими часами",
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
}
},
"providers": {
"title": "Сервисные",
@@ -209,6 +235,20 @@
"typeNut": "NUT (ИБП)",
"typeGooglePhotos": "Google Фото",
"typeWebhook": "Универсальный вебхук",
"typeHomeAssistant": "Home Assistant",
"typeBridgeSelf": "Самомониторинг моста",
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
"haAccessToken": "Долгоживущий токен доступа",
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
"haVerifyTls": "Проверять TLS-сертификат",
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -294,6 +334,13 @@
"selectBoards": "Выберите доски...",
"upsDevices": "ИБП устройства",
"selectUpsDevices": "Выберите ИБП...",
"entities": "Сущности",
"selectEntities": "Выберите сущности...",
"entities_count": "сущность(ей)",
"haEntityGlob": "Фильтр по entity (glob)",
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
"haDomainAllowlist": "Разрешённые домены",
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
"eventTypes": "Типы событий",
"notificationTargets": "Получатели уведомлений",
"scanInterval": "Интервал проверки (секунды)",
@@ -474,6 +521,7 @@
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -617,6 +665,14 @@
"upsOverload": "Перегрузка ИБП",
"scheduledMessage": "Запланированное сообщение",
"webhookReceived": "Вебхук получен",
"haStateChanged": "Состояние сущности изменилось",
"haAutomationTriggered": "Сработала автоматизация",
"haServiceCalled": "Вызвана служба",
"haEventFired": "Прочее событие HA (catch-all)",
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
"bridgeSelfPollFailures": "Сбои опроса трекера",
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
"trackImages": "Фото",
"trackVideos": "Видео",
"favoritesOnly": "Только избранные",
@@ -870,7 +926,58 @@
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить"
"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": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -1020,9 +1127,22 @@
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"commands": {
"bridgeSelf": {
"status": "Состояние моста",
"statusDesc": "Показать счётчики состояния моста",
"thresholds": "Пороги моста",
"thresholdsDesc": "Показать настроенные пороги оповещений",
"reset": "Сбросить счётчик",
"resetDesc": "Вручную сбросить счётчик сбоев",
"health": "Здоровье моста",
"healthDesc": "Краткая однострочная сводка состояния"
}
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
"hideDetails": "Скрыть детали",
"region": "Уведомления"
},
"timezone": {
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
@@ -1031,6 +1151,8 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
@@ -1100,6 +1222,7 @@
},
"common": {
"loading": "Загрузка...",
"auto": "Авто",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
@@ -1241,6 +1364,8 @@
"refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут",
"statsModePage": "Учитывать только события на текущей странице",
"statsModeAll": "Учитывать все события под текущими фильтрами",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1263,7 +1388,9 @@
"providerScheduler": "Запланированные сообщения по расписанию",
"providerNut": "Мониторинг ИБП через NUT",
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
"providerWebhook": "Приём событий через HTTP POST"
"providerWebhook": "Приём событий через HTTP POST",
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
},
"webhookLogs": {
"title": "Последние запросы",
+98
View File
@@ -0,0 +1,98 @@
import type { ProviderDescriptor } from './types';
/**
* Bridge self-monitoring provider descriptor.
*
* The bridge_self provider has no remote URL and no credentials. The only
* configuration surface is the three thresholds below, used by the server
* to decide when an internal failure deserves a notification.
*
* Exactly one bridge_self provider exists per user, auto-seeded on user
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
*/
export const bridgeSelfDescriptor: ProviderDescriptor = {
type: 'bridge_self',
defaultName: 'Bridge Self-Monitoring',
icon: 'mdiAlertCircleOutline',
hasUrl: false,
configFields: [
{
key: 'poll_failure_threshold',
configKey: 'poll_failure_threshold',
label: 'providers.bridgeSelfPollThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 3,
hint: 'providers.bridgeSelfPollThresholdHint',
},
{
key: 'deferred_backlog_threshold',
configKey: 'deferred_backlog_threshold',
label: 'providers.bridgeSelfDeferredThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 100,
hint: 'providers.bridgeSelfDeferredThresholdHint',
},
{
key: 'target_failure_threshold',
configKey: 'target_failure_threshold',
label: 'providers.bridgeSelfTargetThreshold',
type: 'number',
optional: true,
min: 1,
defaultValue: 5,
hint: 'providers.bridgeSelfTargetThresholdHint',
},
],
buildConfig(form) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return {
config: {
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
target_failure_threshold: toInt(form.target_failure_threshold, 5),
},
};
},
hasConfigChanged(form, existing) {
const toInt = (raw: unknown, fallback: number): number => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 1 ? n : fallback;
};
return (
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
);
},
eventFields: [
{
key: 'track_bridge_self_poll_failures',
label: 'trackingConfig.bridgeSelfPollFailures',
default: true,
},
{
key: 'track_bridge_self_deferred_backlog',
label: 'trackingConfig.bridgeSelfDeferredBacklog',
default: true,
},
{
key: 'track_bridge_self_target_failures',
label: 'trackingConfig.bridgeSelfTargetFailures',
default: true,
},
],
collectionMeta: null,
webhookBased: false,
};
@@ -0,0 +1,96 @@
import type { ProviderDescriptor } from './types';
export const homeAssistantDescriptor: ProviderDescriptor = {
type: 'home_assistant',
defaultName: 'Home Assistant',
icon: 'mdiHomeAssistant',
hasUrl: true,
urlPlaceholder: 'http://homeassistant.local:8123',
configFields: [
{
key: 'access_token', configKey: 'access_token',
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
},
{
key: 'verify_tls', configKey: 'verify_tls',
label: 'providers.haVerifyTls',
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
defaultValue: true,
},
],
buildConfig(form, editing) {
const config: Record<string, unknown> = { url: form.url };
if (form.access_token) config.access_token = form.access_token;
// Coerce truthy/falsy form values to a real boolean. The toggle
// control binds to `checked`, so this is normally already a bool,
// but legacy form state may carry the string defaults.
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
if (!editing && !form.access_token) {
return { config, error: 'providers.haAccessTokenRequired' };
}
return { config };
},
hasConfigChanged(form, existing) {
const existingVerify = existing.verify_tls !== false;
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
return (
form.url !== (existing.url || '') ||
!!form.access_token ||
existingVerify !== formVerify
);
},
eventFields: [
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
{
key: 'track_ha_event_fired',
label: 'trackingConfig.haEventFired',
default: false,
hint: 'trackingConfig.haEventFiredHint',
},
],
// entity_glob / domain_allowlist tag-style filters. Stored on the
// tracker's `filters` JSON column (not the flat form root) — the
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
// than a picker, and `filterKey` routes the value into
// `tracker.filters[filterKey]` at save time.
userFilters: [
{
key: 'entity_glob',
filterKey: 'entity_glob',
inputMode: 'tags',
label: 'notificationTracker.haEntityGlob',
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
icon: 'mdiAsterisk',
},
{
key: 'domain_allowlist',
filterKey: 'domain_allowlist',
inputMode: 'tags',
label: 'notificationTracker.haDomainAllowlist',
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
icon: 'mdiTagOutline',
},
],
collectionMeta: {
label: 'notificationTracker.entities',
icon: 'mdiViewList',
placeholder: 'notificationTracker.selectEntities',
countLabel: 'notificationTracker.entities_count',
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
const parts: string[] = [];
if (col.domain) parts.push(col.domain);
if (col.state) parts.push(col.state);
if (parts.length === 0) return col.entity_id || col.id || '';
return parts.join(' · ');
},
},
};
+12
View File
@@ -13,6 +13,7 @@ export const immichDescriptor: ProviderDescriptor = {
icon: 'mdiImageMultiple',
hasUrl: true,
urlPlaceholder: undefined, // uses generic i18n placeholder
supportsAutoOrganize: true,
configFields: [
{
@@ -113,6 +114,17 @@ export const immichDescriptor: ProviderDescriptor = {
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
},
// Periodic summaries / scheduled picks / memories / quiet hours all live on
// the linked tracking & template configs — surface that connection on the
// tracker form so users don't need to read docs to find them.
featureDiscoveryHint: {
messageKey: 'notificationTracker.featureDiscovery',
ctas: [
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
],
},
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
if (newIds.length === 0) return { proceed: true };
+4
View File
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
import { nutDescriptor } from './nut';
import { googlePhotosDescriptor } from './google-photos';
import { webhookDescriptor } from './webhook';
import { homeAssistantDescriptor } from './home-assistant';
import { bridgeSelfDescriptor } from './bridge-self';
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['immich', immichDescriptor],
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
['nut', nutDescriptor],
['google_photos', googlePhotosDescriptor],
['webhook', webhookDescriptor],
['home_assistant', homeAssistantDescriptor],
['bridge_self', bridgeSelfDescriptor],
]);
/** Look up a provider descriptor by type. Returns null for unknown types. */
+46 -8
View File
@@ -20,7 +20,7 @@ export interface ConfigField {
configKey?: string;
/** i18n key for the field label. */
label: string;
type: 'text' | 'password' | 'number' | 'grid-select';
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
@@ -123,17 +123,30 @@ export interface CollectionMeta {
// ── User-identity filters (TrackerForm) ──────────────────────────────
/**
* Declares a filter that picks user identities from the provider's known
* senders. Rendered as a MultiEntitySelect populated from the provider's
* `/users` endpoint. The picked values are stored as `string[]` under
* `tracker.filters[key]`.
* Declares a filter rendered on the tracker form. Two input modes:
*
* * ``picker`` (default) — populated from the provider's ``/users``
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
* allowlists / blocklists where the valid values are known.
* * ``tags`` — free-text chip input. Used for glob patterns and other
* filter values that aren't enumerable in advance.
*
* Either way the picked values are stored as ``string[]`` under
* ``tracker.filters[filterKey ?? key]``.
*/
export interface UserFilterMeta {
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
/** Form field key — used internally for binding. */
key: string;
/** i18n key for the label rendered above the picker. */
/**
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
* omitted (backward compat with the original sender allowlist usage).
*/
filterKey?: string;
/** ``picker`` (default) or ``tags`` for free-text chip input. */
inputMode?: 'picker' | 'tags';
/** i18n key for the label rendered above the input. */
label: string;
/** i18n key for the picker placeholder. */
/** i18n key for the placeholder (picker dropdown or chip input). */
placeholder: string;
/** MDI icon shown on chips and dropdown rows. */
icon: string;
@@ -183,6 +196,31 @@ export interface ProviderDescriptor {
/** Whether this provider stores incoming payload history for debugging. */
payloadHistory?: boolean;
// ── Capability flags ──
/**
* True when the provider exposes asset/people/album endpoints that the
* Auto-Organize action rule editor needs to render its people / album
* pickers (currently only Immich). Used in place of `type === 'immich'`
* checks per CLAUDE.md rule 8.
*/
supportsAutoOrganize?: boolean;
// ── Tracker-form discovery hint ──
/**
* Optional info banner shown on the TrackerForm to point users at related
* configuration pages they would otherwise have to discover from docs.
*
* The hint is rendered as a single i18n message followed by zero or more
* call-to-action links. ``ctas[].href`` may include ``{tracking_config_id}``
* / ``{template_config_id}`` placeholders that the form substitutes from
* the tracker's currently selected default-config IDs (or omits the
* ``?edit=...`` query when the value is 0).
*/
featureDiscoveryHint?: {
messageKey: string;
ctas?: Array<{ href: string; labelKey: string; icon?: string }>;
};
// ── Provider-specific hooks ──
/**
* Called after collection selection changes (before save).
+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();
}
+19 -1
View File
@@ -16,8 +16,19 @@ const DEFAULT_TTL_MS = 30_000; // 30 seconds
export interface EntityCache<T extends { id: number }> {
/** Reactive list of cached entities. */
readonly items: T[];
/** True only during the very first fetch (no cached data yet). */
/**
* True only during the very first fetch — when there is no cached data
* to show yet. Background re-fetches keep `loading` false so consumers
* keep rendering the previous list and don't flash a spinner; observe
* `refreshing` instead if a subtle indicator is needed.
*/
readonly loading: boolean;
/**
* True during any non-first fetch (cached items already populated).
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
* shimmer/disabled state" (refreshing) without sharing one flag.
*/
readonly refreshing: boolean;
/** Timestamp of last successful fetch. */
readonly fetchedAt: number;
/** Fetch entities — returns cached data if fresh, else hits network. */
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
): EntityCache<T> {
let _items = $state<T[]>([]);
let _loading = $state(false);
let _refreshing = $state(false);
let _fetchedAt = $state(0);
function isFresh(): boolean {
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
const existing = inflightRequests.get(endpoint);
if (existing) return existing;
// First-load vs background-refresh state. We split these so consumers
// can keep the previous list visible during a re-fetch (refreshing)
// instead of flashing a spinner placeholder (loading).
const isFirstLoad = _fetchedAt === 0;
if (isFirstLoad) _loading = true;
else _refreshing = true;
const request = api<T[]>(endpoint)
.then((data) => {
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
})
.finally(() => {
_loading = false;
_refreshing = false;
inflightRequests.delete(endpoint);
});
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
return {
get items() { return _items; },
get loading() { return _loading; },
get refreshing() { return _refreshing; },
get fetchedAt() { return _fetchedAt; },
fetch,
invalidate,
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
loadFromStorage();
export const globalProviderFilter = {
get id() {
// If providers are loaded and the stored ID doesn't match any, auto-clear
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
globalProviderFilter.clear();
return null;
}
return _providerId;
},
/**
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
* reconciliation against `providersCache` is the responsibility of a
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
* because writing during read inside a `$state`-derived getter triggers
* Svelte 5's `state_unsafe_mutation` warning.
*/
get id() { return _providerId; },
get initialized() { return _initialized; },
set(id: number | null) {
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
this.set(null);
},
/**
* Drop the stored provider ID if it no longer matches any item in the
* providers cache. Safe to call from a `$effect` after the cache has been
* fetched. Returns true when reconciliation actually changed state, so the
* caller can short-circuit follow-up work.
*/
reconcileWithCache(): boolean {
if (_providerId != null && providersCache.items.length > 0 &&
!providersCache.items.some(p => p.id === _providerId)) {
this.clear();
return true;
}
return false;
},
/** The currently selected provider object (reactive). */
get provider() {
const id = this.id; // triggers stale-ID auto-clear
const id = _providerId;
if (id == null) return null;
return providersCache.items.find(p => p.id === id) ?? null;
},
+63 -1
View File
@@ -212,6 +212,29 @@ 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;
@@ -228,7 +251,12 @@ export interface EventLog {
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;
}
@@ -344,4 +372,38 @@ export interface DashboardStatus {
total_events: number;
recent_events: EventLog[];
command_trackers?: number;
/** Provider name → total event count across ALL events matching the
* current filters (ignores pagination). Powers the "On watch" deck
* when the user opts out of page-scoped stats. */
provider_event_counts?: Record<string, number>;
}
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
export interface ReleaseStatus {
provider: ReleaseProviderKind;
current: string;
latest: string | null;
latest_tag: string | null;
latest_url: string | null;
latest_name: string | null;
latest_body: string | null;
latest_published_at: string | null;
latest_prerelease: boolean;
checked_at: string | null;
update_available: boolean;
error: string | null;
}
export interface ReleaseTestResult {
ok: boolean;
info: {
tag: string;
version: string;
name: string | null;
url: string | null;
published_at: string | null;
prerelease: boolean;
} | null;
error: string | null;
}
+94 -26
View File
@@ -5,7 +5,7 @@
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api';
import { api, errMsg } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -18,7 +18,7 @@
providersCache, notificationTrackersCache, trackingConfigsCache,
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
matrixBotsCache, targetsCache,
matrixBotsCache, targetsCache, releaseStatusCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
@@ -31,34 +31,55 @@
let allProviders = $derived(providersCache.items);
// Sidebar release indicator — reads from the cache populated in onMount.
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
// A screen reader hits the brand-version link on every page — keep the
// label informative only when an update is available, otherwise announce
// the version + product so we don't repeat "Up to date" everywhere.
const releaseTooltip = $derived(
releaseUpdateAvailable
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
: `Notify Bridge v${__APP_VERSION__}`
);
let providerFilterItems = $derived([
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
]);
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
let _syncingFilter = false;
// One-way: the store is the source of truth, the filter widget displays it.
// IconGridSelect mutations route through `onChange` (see template) so we
// never need a paired `$effect` to mirror the local <-> store value, which
// previously required a `_syncingFilter` reentrancy flag.
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
// Reserve the provider-filter row from first paint until the cache resolves.
// Without this, the row appears mid-paint and pushes nav items down on every
// hard reload — the most visible "jump" the user reported.
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
// Sync filter value → store
// Reconcile a stale persisted provider ID against the freshly-loaded
// providers cache. Lives here (not in the store getter) because writing
// `_providerId` from a `$state`-derived getter triggers Svelte's
// `state_unsafe_mutation`. Runs once per cache refresh.
$effect(() => {
const v = providerFilterValue;
if (_syncingFilter) return;
globalProviderFilter.set(v === 0 ? null : v);
// Track `fetchedAt` so we re-run after the cache loads.
void providersCache.fetchedAt;
void providersCache.items.length;
globalProviderFilter.reconcileWithCache();
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
_syncingFilter = true;
providerFilterValue = 0;
_syncingFilter = false;
}
});
function setProviderFilter(v: string | number) {
const num = typeof v === 'number' ? v : Number(v);
globalProviderFilter.set(num === 0 ? null : num);
}
// Collapsed-rail filter cycles through providers via the same setter so the
// store stays the single write path.
function cycleProviderFilter() {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
setProviderFilter(ids[(idx + 1) % ids.length]);
}
let showPasswordForm = $state(false);
let redirecting = $state(false);
@@ -80,7 +101,7 @@
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
}
// Read persisted UI state synchronously so first paint already matches the
@@ -306,6 +327,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -401,7 +423,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}
@@ -421,18 +456,14 @@
{#if showProviderFilter}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
const ids = [0, ...allProviders.map(p => p.id)];
const idx = ids.indexOf(providerFilterValue);
providerFilterValue = ids[(idx + 1) % ids.length];
}}
<button onclick={cycleProviderFilter}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
{/if}
</div>
{/if}
@@ -468,6 +499,7 @@
<a
href={child.href}
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
aria-current={isActive(child.href) ? 'page' : undefined}
>
{#if isActive(child.href)}
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
@@ -487,6 +519,7 @@
href={entry.href}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
title={collapsed ? t(entry.key) : ''}
aria-current={isActive(entry.href) ? 'page' : undefined}
>
{#if isActive(entry.href)}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
@@ -570,6 +603,7 @@
<NavIcon name="mdiMagnify" size={20} />
</button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
aria-expanded={mobileMoreOpen}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<NavIcon name="mdiDotsHorizontal" size={20} />
@@ -584,7 +618,7 @@
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="space-y-3">
@@ -772,6 +806,40 @@
letter-spacing: 0.02em;
font-weight: 500;
}
.brand-version-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
text-decoration: none;
border-radius: 0.3rem;
padding: 1px 4px;
margin: -1px -4px;
transition: color 0.15s, background 0.15s;
}
.brand-version-link:hover {
color: var(--color-foreground);
background: var(--color-glass-strong);
}
.brand-version-link.has-update {
color: var(--color-citrus, #d4a73a);
}
.brand-version-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-citrus, #d4a73a);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
animation: brand-version-pulse 2.4s ease-in-out infinite;
}
@keyframes brand-version-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.brand-version-dot { animation: none; }
.brand-version-link { transition: none; }
}
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
+107 -6
View File
@@ -18,7 +18,7 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerStatsModeItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
@@ -76,6 +76,17 @@
return stored ? parseInt(stored, 10) || 10 : 10;
}
// "On watch" provider deck stats scope. ``'page'`` = derive counts from
// the events visible on the current page (legacy behavior); ``'all'`` =
// use the server-aggregated ``provider_event_counts`` map covering every
// event that matches the active filters.
const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode';
function loadProviderStatsMode(): string {
if (typeof localStorage === 'undefined') return 'page';
const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY);
return stored === 'all' ? 'all' : 'page';
}
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
// this whitelist in sync with that helper so a stale localStorage
@@ -95,6 +106,7 @@
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds());
let providerStatsMode = $state(loadProviderStatsMode());
let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade
@@ -128,6 +140,14 @@
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
});
// Persist the provider deck stats mode the same way.
let _providerStatsHydrated = false;
$effect(() => {
const v = providerStatsMode;
if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; }
if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v);
});
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
@@ -197,6 +217,7 @@
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
provider_event_counts: next.provider_event_counts,
};
return;
}
@@ -298,9 +319,21 @@
: displayProviders);
// === Provider deck — derive activity counts from recent events ===
//
// ``providerStatsMode`` controls the scope: ``'page'`` derives counts
// from the visible page (legacy), ``'all'`` uses the server-aggregated
// ``provider_event_counts`` map covering every event under the active
// filters regardless of pagination.
const providerEventCounts = $derived.by(() => {
const counts = new Map<string, number>();
if (!status) return counts;
if (providerStatsMode === 'all' && status.provider_event_counts) {
for (const [name, total] of Object.entries(status.provider_event_counts)) {
if (!name) continue;
counts.set(name, total);
}
return counts;
}
for (const ev of status.recent_events) {
const k = ev.provider_name || '';
if (!k) continue;
@@ -724,6 +757,37 @@
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#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
@@ -781,11 +845,18 @@
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
<div class="panel-head-actions">
<div class="w-32" title={t('dashboard.statsModeTitle')}>
<IconGridSelect items={providerStatsModeItems()}
bind:value={providerStatsMode}
columns={2} compact />
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</div>
</header>
{#if sectionExpanded.on_watch}
@@ -1334,6 +1405,36 @@
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
* Coloured to match the verb (held = primary glow, late = success, drop
* = muted). The icon is intentionally small so the badge doesn't pull
* focus from the event verb itself. */
.dispatch-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
margin-left: 0.4rem;
white-space: nowrap;
}
.dispatch-badge--deferred {
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
}
.dispatch-badge--late {
color: var(--color-success, #16a34a);
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
}
.dispatch-badge--dropped {
color: var(--color-muted-foreground);
opacity: 0.85;
}
.signal-when {
text-align: right;
font-size: 0.7rem;
+76 -27
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -22,6 +22,7 @@
import ExecutionHistory from './ExecutionHistory.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { Action, ActionRule } from '$lib/types';
let allActions = $derived(actionsCache.items);
@@ -98,8 +99,8 @@
capabilitiesCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('actions.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('actions.loadError'));
} finally { loaded = true; highlightFromUrl(); }
}
@@ -137,7 +138,7 @@
}
showForm = false; editing = null; actionsCache.invalidate(); await load();
snackSuccess(t('actions.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -150,7 +151,7 @@
await api(`/actions/${id}`, { method: 'DELETE' });
actionsCache.invalidate(); await load();
snackSuccess(t('actions.deleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function executeAction(id: number, dryRun = false) {
@@ -164,7 +165,7 @@
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
snackSuccess(msg);
actionsCache.invalidate(); await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
executing = { ...executing, [id]: false };
}
@@ -193,6 +194,51 @@
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
@@ -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]} />
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -7,6 +7,7 @@
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers';
import type { ActionRule } from '$lib/types';
let { actionId, actionType, providerId }: { actionId: number; actionType: string; providerId: number } = $props();
@@ -47,14 +48,16 @@
loading = true;
try {
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
loading = false;
}
async function loadProviderData() {
if (actionType !== 'auto_organize') return;
const provider = providersCache.items.find((p: any) => p.id === providerId);
if (!provider || provider.type !== 'immich') return;
if (!provider) return;
const descriptor = getDescriptor(provider.type);
if (!descriptor?.supportsAutoOrganize) return;
try {
const [p, a] = await Promise.all([
api<any>(`/providers/${providerId}/people`),
@@ -79,7 +82,7 @@
resetNewRule();
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
submitting = false;
}
@@ -91,7 +94,7 @@
});
await loadRules();
snackSuccess(t('actions.ruleSaved'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function deleteRule(ruleId: number) {
@@ -99,7 +102,7 @@
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
await loadRules();
snackSuccess(t('actions.ruleDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleRule(rule: ActionRule) {
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,7 @@
emailBotsCache.fetch(true),
matrixBotsCache.fetch(true),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
</script>
+39 -13
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -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 = {
@@ -64,7 +89,7 @@
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { emailSubmitting = false; }
}
@@ -74,10 +99,10 @@
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteEmail = null; }
}
@@ -90,7 +115,7 @@
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || t('emailBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
@@ -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" />
+37 -13
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -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 = {
@@ -62,7 +85,7 @@
snackSuccess(t('snack.matrixBotCreated'));
}
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { matrixSubmitting = false; }
}
@@ -72,10 +95,10 @@
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDeleteMatrix = null; }
}
@@ -88,7 +111,7 @@
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
else snackError(res.error || t('matrixBot.operationFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
matrixTesting = { ...matrixTesting, [botId]: false };
}
</script>
@@ -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" />
+72 -27
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
@@ -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; }
@@ -74,7 +105,7 @@
snackSuccess(t('snack.botRegistered'));
}
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
finally { submitting = false; }
}
@@ -84,10 +115,10 @@
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -116,7 +147,7 @@
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
@@ -125,11 +156,15 @@
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
snackSuccess(t('telegramBot.chatDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
// `desc` is the only locale-sensitive field — language *names* are intentionally
// shown in their own language (English under EN, Русский under RU, …) so users
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
// for the no-override row is translated.
let LANG_ITEMS = $derived([
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
@@ -146,7 +181,7 @@
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
]);
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
@@ -158,7 +193,7 @@
c.id === chat.id ? { ...c, language_override: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleChatCommands(botId: number, chat: TelegramChat) {
@@ -171,7 +206,7 @@
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function loadListenerStatus(botId: number) {
@@ -201,7 +236,7 @@
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
),
};
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function syncCommands(botId: number) {
@@ -210,7 +245,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -223,7 +258,7 @@
await loadWebhookStatus(botId);
}
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -243,7 +278,7 @@
} else {
snackError(res.error || t('telegramBot.webhookFailed'));
}
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -253,7 +288,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
modeChanging = { ...modeChanging, [botId]: false };
}
@@ -284,7 +319,7 @@
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || t('telegramBot.saveFailed'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
chatTesting = { ...chatTesting, [key]: false };
}
@@ -343,18 +378,19 @@
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
</div>
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: (bot.update_mode || 'none') === 'polling'
@@ -362,10 +398,11 @@
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<MetaStrip tiles={telegramBotTiles(bot)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
disabled={chatsLoading[bot.id]}
@@ -431,6 +468,10 @@
</div>
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
<button
type="button"
role="switch"
aria-checked={chat.commands_enabled}
aria-label={t('telegramBot.commandsToggle')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={t('telegramBot.commandsToggle')}
onclick={() => toggleChatCommands(bot.id, chat)}>
@@ -478,6 +519,10 @@
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
</div>
<button
type="button"
role="switch"
aria-checked={trk.enabled}
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => toggleListenerEnabled(bot.id, trk)}>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -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 {
@@ -104,10 +105,46 @@
commandTemplateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: cfg.provider_type,
tone: 'lavender',
mono: true,
});
const cmdCount = (cfg.enabled_commands || []).length;
tiles.push({
icon: 'mdiSlashForward',
value: String(cmdCount),
label: t('commandConfig.commands'),
tone: cmdCount > 0 ? 'mint' : 'coral',
});
tiles.push({
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
tone: 'sky',
});
tiles.push({
icon: 'mdiNumeric',
value: String(cfg.default_count),
label: t('commandConfig.defaultCount'),
tone: 'citrus',
});
if (cfg.command_template_config_id) {
tiles.push({
icon: 'mdiCodeBracesBox',
label: templateName(cfg.command_template_config_id),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
// Auto-select first provider type with commands
@@ -177,7 +214,7 @@
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -190,10 +227,10 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
snackError(errMsg(err));
}
finally { confirmDelete = null; }
}
@@ -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>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
@@ -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;
@@ -212,8 +213,8 @@
allCmdTplConfigs = cfgs;
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
} catch (err: unknown) {
error = errMsg(err, t('common.loadError'));
snackError(error);
} finally {
loaded = true;
@@ -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);
@@ -315,9 +354,10 @@
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
}
}
@@ -368,8 +408,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -405,11 +445,12 @@
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
} finally {
confirmDelete = null;
}
@@ -548,7 +589,7 @@
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -587,25 +628,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t } from '$lib/i18n';
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
@@ -21,6 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
@@ -106,7 +107,7 @@
providersCache.fetch(), commandConfigsCache.fetch(),
telegramBotsCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
}
@@ -167,7 +168,7 @@
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { submitting = false; }
}
@@ -179,7 +180,7 @@
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandTrackerDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { confirmDelete = null; }
}
};
@@ -192,7 +193,7 @@
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
toggling = { ...toggling, [trk.id]: false };
}
@@ -225,7 +226,7 @@
snackSuccess(t('snack.listenerAdded'));
await loadListeners(trkId);
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingListener = { ...addingListener, [trkId]: false };
}
@@ -234,7 +235,7 @@
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
snackSuccess(t('snack.listenerRemoved'));
await loadListeners(trkId);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-listener album scope editing
@@ -263,7 +264,7 @@
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
function providerName(id: number): string {
@@ -272,6 +273,32 @@
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
@@ -341,34 +368,37 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each trackers as trk}
<Card hover entityId={trk.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
{#if trk.listener_count !== undefined}
<span class="text-xs text-[var(--color-muted-foreground)]">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandTrackerTiles(trk)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '' : ''}
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
</div>
@@ -443,7 +473,7 @@
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<span aria-hidden="true">В·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { login } from '$lib/auth.svelte';
import { t, getLocale, setLocale } from '$lib/i18n';
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -37,7 +37,7 @@
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup');
} catch {
// The backend is unreachable surface that distinctly so the user
// The backend is unreachable — surface that distinctly so the user
// doesn't blame the login form for a network/backend problem.
backendDown = true;
}
@@ -50,8 +50,8 @@
try {
await login(username, password);
window.location.href = '/';
} catch (err: any) {
error = err.message || t('auth.loginFailed');
} catch (err: unknown) {
error = errMsg(err, t('auth.loginFailed'));
}
submitting = false;
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { api, parseDate } from '$lib/api';
import { api, parseDate , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
@@ -22,6 +22,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
@@ -165,8 +166,8 @@
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) {
loadError = err.message || t('common.loadFailed');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadFailed'));
snackError(loadError);
} finally { loaded = true; highlightFromUrl(); }
}
@@ -288,7 +289,7 @@
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
}
async function autoCreateLinks() {
@@ -300,8 +301,8 @@
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
} catch (err: unknown) {
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
}
}
}
@@ -323,7 +324,7 @@
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
}
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
@@ -334,7 +335,7 @@
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
confirmDelete = null;
}
@@ -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;
@@ -402,7 +451,7 @@
newLinkTemplateConfigId[trackerId] = 0;
await load();
snackSuccess(t('snack.targetLinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
addingTarget = { ...addingTarget, [trackerId]: false };
}
@@ -411,7 +460,7 @@
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
@@ -421,7 +470,7 @@
body: JSON.stringify({ [field]: value }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
@@ -443,8 +492,8 @@
} else {
snackSuccess(t('snack.targetTestSent'));
}
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
ttTesting = { ...ttTesting, [key]: '' };
}
@@ -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)}
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api , errMsg} from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -23,7 +23,7 @@
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place the
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
@@ -39,8 +39,8 @@
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} catch (err: unknown) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
} finally {
replacing = { ...replacing, [album.id]: false };
}
@@ -7,6 +7,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { getDescriptor } from '$lib/providers';
interface Props {
@@ -58,9 +59,36 @@
}: Props = $props();
let descriptor = $derived(getDescriptor(providerType));
let isScheduler = $derived(providerType === 'scheduler');
let isWebhook = $derived(descriptor?.webhookBased ?? false);
let colMeta = $derived(descriptor?.collectionMeta);
// Providers without a collection (currently just the scheduler) drive the
// scheduler-specific form layout. Reading from the descriptor keeps the
// branch out of the component and lets a future provider opt in by
// declaring `collectionMeta: null`.
let isScheduler = $derived(colMeta == null);
let isWebhook = $derived(descriptor?.webhookBased ?? false);
/**
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
* descriptor-declared CTA href. When the corresponding form field is unset
* (value 0), strip the entire `?edit=...` query so the link still goes to
* the list page. Centralising this here avoids per-provider href logic
* leaking back into the template.
*/
function resolveHintHref(href: string): string {
const tcId = form.default_tracking_config_id;
const tplId = form.default_template_config_id;
if (href.includes('{tracking_config_id}')) {
return tcId
? href.replace('{tracking_config_id}', String(tcId))
: href.split('?')[0];
}
if (href.includes('{template_config_id}')) {
return tplId
? href.replace('{template_config_id}', String(tplId))
: href.split('?')[0];
}
return href;
}
// Custom variable management for scheduler
function addVariable() {
@@ -123,14 +151,24 @@
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
{#each descriptor.userFilters as uf (uf.key)}
{@const filterKey = uf.filterKey ?? uf.key}
<div>
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[uf.key] || []}
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
placeholder={t(uf.placeholder)}
/>
{#if uf.inputMode === 'tags'}
<TagInput
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
icon={uf.icon}
/>
{:else}
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[filterKey] || []}
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
placeholder={t(uf.placeholder)}
/>
{/if}
</div>
{/each}
{/if}
@@ -220,29 +258,27 @@
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
live on the tracking config, not on the tracker itself. The hint
content (message + CTAs) is declared on the provider descriptor so
each provider can surface its own discoverability links without
embedding `if (type === 'xyz')` here. -->
{#if descriptor?.featureDiscoveryHint}
{@const hint = descriptor.featureDiscoveryHint}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<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>
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
{#if hint.ctas && hint.ctas.length > 0}
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
{#each hint.ctas as cta}
<a href={resolveHintHref(cta.href)}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
{t(cta.labelKey)}
</a>
{/each}
</div>
{/if}
</div>
</div>
{/if}
+110 -35
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -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();
@@ -107,8 +169,8 @@
try {
await providersCache.fetch(true);
loadError = '';
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('providers.loadError'));
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
@@ -175,7 +237,7 @@
}
showForm = false; editing = null; providersCache.invalidate(); await load();
snackSuccess(t('snack.providerSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
submitting = false;
}
@@ -186,10 +248,10 @@
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
}
</script>
@@ -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">
@@ -259,6 +321,11 @@
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || (field.required === 'create-only' && !editing)}
@@ -292,9 +359,11 @@
{/if}
{#if !showForm && allProviders.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="list-stack mb-3">
<div class="flex items-center gap-2">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{/if}
@@ -307,37 +376,43 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
</div>
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
<div class="list-row__secondary">
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@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>
@@ -107,6 +107,11 @@
{:else if field.type === 'number'}
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{:else if field.type === 'toggle'}
<label class="toggle-switch">
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
<span class="toggle-track"></span>
</label>
{:else}
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
required={field.required === true || field.required === 'create-only'}
+28 -1
View File
@@ -6,11 +6,12 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
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';
@@ -36,6 +37,11 @@
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 = {
@@ -48,6 +54,11 @@
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);
@@ -86,6 +97,8 @@
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
// 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;
@@ -108,6 +121,12 @@
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: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
@@ -171,6 +190,14 @@
/>
</div>
<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}
/>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
@@ -2,6 +2,7 @@
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';
@@ -104,6 +105,7 @@
{#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>
@@ -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>
@@ -2,6 +2,7 @@
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';
@@ -81,6 +82,19 @@
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>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, fetchAuth } from '$lib/api';
import { api, fetchAuth , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -97,9 +97,10 @@
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
loaded = true;
}
@@ -110,7 +111,7 @@
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function applyAndRestart(): Promise<void> {
@@ -131,9 +132,9 @@
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
} catch (err: unknown) {
restartingOverlay = false;
snackError(err.message);
snackError(errMsg(err));
}
}
@@ -144,8 +145,8 @@
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
creatingBackup = false;
}
@@ -178,8 +179,8 @@
a.click();
URL.revokeObjectURL(url);
snackSuccess(t('backup.exportSuccess'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
exporting = false;
}
@@ -202,8 +203,8 @@
formData.append('file', importFile);
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
validating = false;
}
@@ -230,8 +231,8 @@
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
importing = false;
}
@@ -246,8 +247,8 @@
body: JSON.stringify(scheduledSettings),
});
snackSuccess(t('backup.scheduleSaved'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
savingSchedule = false;
}
@@ -258,8 +259,8 @@
loadingFiles = true;
try {
backupFiles = await api<BackupFile[]>('/backup/files');
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
loadingFiles = false;
}
@@ -275,8 +276,8 @@
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -286,8 +287,8 @@
snackSuccess(t('backup.fileDeleted'));
confirmDeleteFile = '';
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
</script>
+2 -1
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { setup } from '$lib/auth.svelte';
import { errMsg } from '$lib/api';
import { t } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
@@ -25,7 +26,7 @@
try {
await setup(username, password);
window.location.href = '/';
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
submitting = false;
}
</script>
+85 -28
View File
@@ -3,7 +3,7 @@
import { SvelteSet } from 'svelte/reactivity';
import { slide } from 'svelte/transition';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
@@ -15,6 +15,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.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';
@@ -25,7 +26,7 @@
import ReceiverSection from './ReceiverSection.svelte';
import BotGroupHeader from './BotGroupHeader.svelte';
// ── Helpers ──
// ──── Helpers ────
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
@@ -73,7 +74,7 @@
return recv.receiver_key || '?';
}
// ── Constants ──
// ──── Constants ────
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
@@ -94,7 +95,54 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
// ── Derived state ──
function targetTiles(target: NotificationTarget): MetaTile[] {
const tiles: MetaTile[] = [];
// Type tile — useful when the "all types" filter is active and rows
// from multiple types appear side-by-side. The receivers count is
// already shown inside the `target-summary` button, so we don't repeat
// it as a tile.
tiles.push({
icon: TYPE_ICONS[target.type] || 'mdiTarget',
label: target.type,
tone: 'lavender',
mono: true,
});
const botName = getBotName(target);
if (botName) {
tiles.push({
icon: 'mdiRobot',
label: botName,
tone: 'sky',
});
}
// Telegram targets expose a chat label in config — surface it so the
// row reads "Telegram · @bot · Family chat" without expanding.
const cfg = (target.config || {}) as Record<string, any>;
if (target.type === 'telegram' && cfg.chat_id) {
tiles.push({
icon: 'mdiChat',
label: String(cfg.chat_id),
tone: 'orchid',
mono: true,
});
}
// Webhook target — show host
if (target.type === 'webhook' && cfg.url) {
let host = String(cfg.url);
try { host = new URL(host).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiLinkVariant',
label: host,
hint: String(cfg.url),
href: String(cfg.url),
tone: 'orchid',
mono: true,
});
}
return tiles;
}
// ──── Derived state ────
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
@@ -110,7 +158,7 @@
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
// ── Target form state ──
// ──── Target form state ────
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -156,7 +204,7 @@
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
// ──── Receiver inline form state ────
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
@@ -180,7 +228,7 @@
if (!expandedTargets.has(id)) expandedTargets.add(id);
}
// ── Effects ──
// ──── Effects ────
// Reset form when switching target type tabs
$effect(() => {
@@ -191,11 +239,11 @@
addingReceiverForTarget = null;
});
// ── Data loading ──
// ──── Data loading ────
onMount(load);
// ── Bot grouping ──
// ──── Bot grouping ────
type TargetGroup = {
key: string;
@@ -307,8 +355,8 @@
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('common.loadError');
} catch (err: unknown) {
loadError = errMsg(err, t('common.loadError'));
snackError(loadError);
} finally {
loaded = true;
@@ -334,7 +382,7 @@
} catch (e) { console.warn('Failed to discover bot chats:', e); }
}
// ── Target CRUD ──
// ──── Target CRUD ────
function openNew() {
form = defaultForm();
@@ -427,9 +475,10 @@
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} catch (err: unknown) {
const m = errMsg(err);
error = m;
snackError(m);
} finally {
submitting = false;
}
@@ -440,7 +489,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -449,15 +498,16 @@
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
} catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
const m = errMsg(err);
error = m;
snackError(m);
}
}
// ── Receiver CRUD ──
// ──── Receiver CRUD ────
async function openReceiverForm(targetId: number, targetType: string) {
// Force a remount of any picker palette when the same target is reopened
@@ -527,8 +577,8 @@
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
} finally {
receiverSubmitting = false;
}
@@ -542,7 +592,7 @@
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function removeReceiver(targetId: number, receiverId: number) {
@@ -550,7 +600,7 @@
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
@@ -565,7 +615,7 @@
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function testReceiver(targetId: number, receiverId: number) {
@@ -574,7 +624,7 @@
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
} catch (err: unknown) { snackError(errMsg(err)); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
@@ -660,7 +710,7 @@
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="target-summary"
@@ -682,6 +732,7 @@
<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)} />
@@ -765,7 +816,7 @@
}
.target-summary {
flex: 1;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
@@ -780,6 +831,12 @@
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);
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -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';
@@ -260,7 +261,7 @@
capabilitiesCache.fetch(),
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
@@ -346,7 +347,7 @@
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
/**
@@ -403,8 +404,8 @@
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
} catch (err: unknown) {
snackError(errMsg(err));
}
}
@@ -426,16 +427,55 @@
setTimeout(() => refreshAllPreviews(), 100);
}
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
// Locale coverage — count unique locales present across all slots
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -586,7 +626,7 @@
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);"> {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
@@ -627,24 +667,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={templateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
@@ -26,6 +26,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[]> = {
@@ -157,8 +158,8 @@
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} catch (err: unknown) {
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
} finally {
previewLoading = false;
}
@@ -216,7 +217,7 @@
});
async function load() {
try { await trackingConfigsCache.fetch(true); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
}
@@ -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 };
@@ -252,7 +285,7 @@
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.trackingConfigSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
@@ -261,10 +294,10 @@
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) {
catch (err: unknown) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
const m = errMsg(err); error = m; snackError(m);
}
finally { confirmDelete = null; }
}
@@ -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>
+225 -200
View File
@@ -1,200 +1,225 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: any) { error = err.message; snackError(err.message); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<script lang="ts">
import { onMount } from 'svelte';
import { api, parseDate , errMsg} from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import Modal from '$lib/components/Modal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
let users = $state<User[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
let loaded = $state(false);
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
// Admin reset password
let resetUserId = $state<number | null>(null);
let resetUsername = $state('');
let resetPassword = $state('');
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
finally { loaded = true; }
}
async function create(e: SubmitEvent) {
e.preventDefault(); error = '';
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
}
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); } catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
finally { confirmDelete = null; }
}
};
}
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
resetMsg = t('common.passwordChanged');
resetSuccess = true;
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
title={t('users.title')}
emphasis={t('users.titleEmphasis')}
description={t('users.description')}
crumb={t('crumbs.systemAccess')}
count={users.length}
countLabel={t('users.countLabel')}
>
<Button size="sm" onclick={() => showForm = !showForm}>
{showForm ? t('users.cancel') : t('users.addUser')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
<Button type="submit">{t('users.create')}</Button>
</form>
</Card>
{/if}
{#if users.length === 0}
<Card>
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<Button type="submit" class="w-full">
{t('common.save')}
</Button>
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.7.2"
version = "0.8.2"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -65,6 +65,18 @@ class EventType(str, Enum):
UPS_REPLACE_BATTERY = "ups_replace_battery"
UPS_OVERLOAD = "ups_overload"
# Home Assistant events
HA_STATE_CHANGED = "ha_state_changed"
HA_AUTOMATION_TRIGGERED = "ha_automation_triggered"
HA_SERVICE_CALLED = "ha_service_called"
HA_EVENT_FIRED = "ha_event_fired"
# Bridge self-monitoring events — emitted by the bridge itself when
# internal failures cross configured thresholds.
BRIDGE_SELF_POLL_FAILURES = "bridge_self_poll_failures"
BRIDGE_SELF_DEFERRED_BACKLOG = "bridge_self_deferred_backlog"
BRIDGE_SELF_TARGET_FAILURES = "bridge_self_target_failures"
@dataclass
class ServiceEvent:
@@ -107,6 +107,12 @@ class NotificationDispatcher:
# Optional shared session owned by the caller; when supplied we reuse
# its connection pool instead of opening a fresh per-dispatch session.
self._shared_session = session
# Per-dispatch render cache, keyed by locale. Populated by
# ``_send_to_target`` and consumed inside ``_message_for_receiver``
# so a 100-receiver fan-out renders each unique locale once.
# Initialized to empty so handlers called outside the normal
# dispatch path (tests) still see a valid dict.
self._render_cache: dict[str, str] = {}
@contextlib.asynccontextmanager
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
@@ -198,20 +204,49 @@ class NotificationDispatcher:
def _message_for_receiver(
self, receiver: Receiver, default_message: str,
event: ServiceEvent, target: TargetConfig,
cache: dict[str, str] | None = None,
) -> str:
if receiver.locale and receiver.locale != target.locale:
return self._render_message(event, target, receiver.locale)
return default_message
"""Render message respecting receiver locale, with optional cache.
The ``cache`` dict (typically created in ``_send_to_target`` and
threaded through the per-channel ``_send_*`` handlers) memoizes
per-locale renders so a 100-receiver fan-out with two locales
renders twice instead of one hundred times.
"""
loc = receiver.locale or target.locale
if loc == target.locale:
return default_message
if cache is not None:
cached = cache.get(loc)
if cached is not None:
return cached
rendered = self._render_message(event, target, loc)
cache[loc] = rendered
return rendered
return self._render_message(event, target, loc)
async def _send_to_target(
self, event: ServiceEvent, target: TargetConfig
) -> dict[str, Any]:
"""Dispatch to a single target via the registered handler."""
"""Dispatch to a single target via the registered handler.
Builds a per-locale render cache once and threads it through the
send handler. The cache is keyed by receiver locale; the default
locale's render lives in ``default_message`` and is short-circuited
before any cache lookup.
"""
default_message = self._render_message(event, target, target.locale)
send_method = _PROVIDER_HANDLERS.get(target.type)
if send_method is None:
return {"success": False, "error": f"Unknown target type: {target.type}"}
return await send_method(self, target, default_message, event)
# Stash the cache on the dispatcher instance for the duration of
# this dispatch — handlers pick it up via _message_for_receiver.
# Avoids changing every _send_* signature.
self._render_cache: dict[str, str] = {}
try:
return await send_method(self, target, default_message, event)
finally:
self._render_cache = {}
# ------------------------------------------------------------------
# Asset preload (Telegram-specific)
@@ -352,7 +387,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
return {"success": False, "error": "Invalid telegram receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
text_result = await client.send_message(
chat_id=receiver.chat_id,
text=message,
@@ -407,7 +442,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
return {"success": False, "error": "Invalid webhook receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
payload = {
"message": message,
"event_type": event.event_type.value,
@@ -450,7 +485,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, EmailReceiver) or not receiver.email:
return {"success": False, "error": "Invalid email receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
# body_html=None lets EmailClient build a safely-escaped HTML
# alternative from body_text instead of trusting user content.
return await email_client.send(
@@ -479,7 +514,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
return {"success": False, "error": "Invalid discord receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
@@ -501,7 +536,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
return {"success": False, "error": "Invalid slack receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(receiver.webhook_url, message, username=username)
results = await self._fan_out(target.receivers, send_one)
@@ -530,7 +565,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
return {"success": False, "error": "Invalid ntfy receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
return await client.send(
server_url, receiver.topic, message,
title=title, priority=receiver.priority, auth_token=auth_token,
@@ -563,7 +598,7 @@ class NotificationDispatcher:
async def send_one(receiver: Receiver) -> dict[str, Any]:
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
return {"success": False, "error": "Invalid matrix receiver"}
message = self._message_for_receiver(receiver, default_message, event, target)
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
# body_html is the same plain text — Matrix accepts the
# raw message as both ``body`` and ``formatted_body``.
# If templates emit HTML in the future, generate a
@@ -222,21 +222,48 @@ class TelegramClient:
"""SSRF-guarded GET that returns ``(data, error)``.
Validates the URL via ``avalidate_outbound_url`` before any HTTP
traffic. Errors are returned (not raised) and stripped of any
embedded secrets before they propagate to the operator-visible
result dict.
traffic. Redirects are walked manually so each ``Location`` is
re-validated without this an attacker-controlled origin could
302 to a private-IP target after the initial guard passed.
Errors are returned (not raised) and stripped of any embedded
secrets before they propagate to the operator-visible result
dict.
"""
max_redirects = 3
current_url = url
try:
await avalidate_outbound_url(url)
await avalidate_outbound_url(current_url)
except UnsafeURLError as err:
return None, f"Unsafe URL: {redact_exc(err)}"
try:
async with self._session.get(
url, headers=headers or {}, timeout=_DOWNLOAD_TIMEOUT,
) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
for _ in range(max_redirects + 1):
async with self._session.get(
current_url,
headers=headers or {},
timeout=_DOWNLOAD_TIMEOUT,
allow_redirects=False,
) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location")
if not loc:
return None, f"HTTP {resp.status} without Location header"
# ``resp.url`` is a yarl.URL; ``.join`` resolves
# relative redirects (``/foo/bar``) against it.
from yarl import URL as _URL
try:
next_url = str(resp.url.join(_URL(loc)))
except (ValueError, TypeError):
return None, "Malformed redirect Location"
try:
await avalidate_outbound_url(next_url)
except UnsafeURLError as err:
return None, f"Unsafe redirect: {redact_exc(err)}"
current_url = next_url
continue
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
return None, f"Too many redirects (>{max_redirects})"
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
return None, redact_exc(err)
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from notify_bridge_core.models.events import ServiceEvent
@@ -21,6 +21,14 @@ class ServiceProviderType(str, Enum):
NUT = "nut"
GOOGLE_PHOTOS = "google_photos"
WEBHOOK = "webhook"
HOME_ASSISTANT = "home_assistant"
BRIDGE_SELF = "bridge_self"
# Callback signature for push-style providers: a coroutine that accepts a
# parsed ServiceEvent and is expected to enqueue it for dispatch. Returning
# None keeps the contract narrow — error handling stays inside the callback.
EventEmitCallback = Callable[["ServiceEvent"], Awaitable[None]]
class ServiceProvider(ABC):
@@ -28,10 +36,27 @@ class ServiceProvider(ABC):
A service provider connects to an external service (e.g., Immich photo server)
and can poll for changes, producing generic ServiceEvent objects.
Two ingest modes coexist on this base class:
* Polling providers (Immich, NUT, Google Photos, Scheduler) implement
:meth:`poll` and leave :attr:`supports_subscription` False.
* Webhook providers (Gitea, Planka, generic Webhook) no-op :meth:`poll`
and receive events out-of-band via ``api/webhooks.py``.
* Subscription providers (Home Assistant) flip
:attr:`supports_subscription` to True and implement :meth:`subscribe`
to run a long-lived task that pushes events through an
``emit`` callback. They typically no-op :meth:`poll`.
"""
provider_type: ServiceProviderType
# When True, the lifecycle layer (server-side subscription manager) starts
# a long-running task that calls :meth:`subscribe` instead of registering
# this provider with the polling scheduler. Default False keeps the
# legacy poll/webhook flow intact for every existing provider.
supports_subscription: bool = False
@abstractmethod
async def connect(self) -> bool:
"""Connect to the service and verify connectivity.
@@ -59,6 +84,27 @@ class ServiceProvider(ABC):
Tuple of (list of events detected, updated state dict).
"""
async def subscribe(self, emit: EventEmitCallback) -> None:
"""Run a long-lived subscription that calls ``emit`` for each event.
Override on providers with :attr:`supports_subscription` = True. The
implementation is expected to:
* Loop until cancelled (the subscription manager uses
:func:`asyncio.Task.cancel` on shutdown).
* Handle its own reconnect with exponential backoff never propagate
transient network errors to the caller.
* Pass parsed :class:`ServiceEvent` instances to ``emit`` for
enqueueing/dispatch. The callback is responsible for routing.
The default implementation raises :class:`NotImplementedError` so
accidental wiring of a polling provider into the subscription manager
fails loudly rather than silently doing nothing.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support subscription-based ingest"
)
@abstractmethod
def get_available_variables(self) -> list[TemplateVariableDefinition]:
"""Return the template variables this provider makes available."""
@@ -0,0 +1,39 @@
"""Bridge self-monitoring service provider.
Unlike external providers (Immich, Gitea, NUT, ...), the ``bridge_self``
provider does not connect to any remote service. Its sole purpose is to
give operators a configurable surface (thresholds + notification slots
+ trackers + targets) for events that the bridge itself emits when its
internal subsystems fail.
Three failure conditions are surfaced as :class:`ServiceEvent` instances
through the same dispatch pipeline that all other providers use:
* ``bridge_self_poll_failures`` N consecutive poll failures for
any tracker exceed the configured threshold.
* ``bridge_self_deferred_backlog`` pending ``deferred_dispatch`` row
count crosses the configured threshold.
* ``bridge_self_target_failures`` N consecutive 5xx / network failures
for a single notification target.
Events are constructed by ``services/bridge_self.py`` on the server side
(it owns DB access for looking up the bridge_self provider per user)
and then fed into ``dispatch_provider_event`` like any other event.
"""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .event_parser import build_event
from .provider import BRIDGE_SELF_VARIABLES, BridgeSelfServiceProvider
# Register variables so the validator and template-vars API see them.
registry.register_provider_variables(
ServiceProviderType.BRIDGE_SELF, BRIDGE_SELF_VARIABLES,
)
__all__ = [
"BRIDGE_SELF_VARIABLES",
"BridgeSelfServiceProvider",
"build_event",
]
@@ -0,0 +1,89 @@
"""Bridge self-monitoring event parser.
The bridge generates these events from internal subsystems (watcher,
scheduler, dispatcher) the parser turns a flat payload dict into the
generic :class:`ServiceEvent` shape that the rest of the dispatch
pipeline expects.
Payload shape::
{
"failure_type": "poll_failures" | "deferred_backlog" | "target_failures",
"subject_id": int, # tracker_id, target_id, or 0
"subject_name": str,
"count": int, # consecutive failures or pending count
"threshold": int,
"last_error": str, # may be empty
"details": dict[str, Any], # extra context
}
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
# Defensive cap on the persisted error message; very long tracebacks would
# bloat the EventLog details JSON column otherwise.
_MAX_ERROR_LEN = 1000
_FAILURE_TYPE_TO_EVENT: dict[str, EventType] = {
"poll_failures": EventType.BRIDGE_SELF_POLL_FAILURES,
"deferred_backlog": EventType.BRIDGE_SELF_DEFERRED_BACKLOG,
"target_failures": EventType.BRIDGE_SELF_TARGET_FAILURES,
}
def build_event(
payload: dict[str, Any],
*,
provider_name: str = "Bridge Self-Monitoring",
timestamp: datetime | None = None,
) -> ServiceEvent | None:
"""Convert a self-monitoring payload dict into a ServiceEvent.
Returns None for malformed payloads (unknown failure_type or missing
keys) the caller drops without raising so a misbehaving emitter
can never tip over the dispatch pipeline.
"""
if not isinstance(payload, dict):
return None
failure_type = payload.get("failure_type")
event_type = _FAILURE_TYPE_TO_EVENT.get(str(failure_type) if failure_type else "")
if event_type is None:
return None
subject_id = int(payload.get("subject_id") or 0)
subject_name = str(payload.get("subject_name") or "")
count = int(payload.get("count") or 0)
threshold = int(payload.get("threshold") or 0)
last_error = str(payload.get("last_error") or "")[:_MAX_ERROR_LEN]
details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
when = timestamp or datetime.now(timezone.utc)
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.BRIDGE_SELF,
provider_name=provider_name,
# ``collection_id`` / ``collection_name`` are required fields on
# ServiceEvent; we use the subject so quiet-hours / dedupe logic
# treats different subjects as distinct streams.
collection_id=str(subject_id),
collection_name=subject_name or str(failure_type),
timestamp=when,
extra={
"failure_type": str(failure_type),
"subject_id": subject_id,
"subject_name": subject_name,
"count": count,
"threshold": threshold,
"last_error": last_error,
"details": dict(details),
},
)
@@ -0,0 +1,148 @@
"""Bridge self-monitoring service provider — emits internal-failure events.
This is a passive provider: it does not connect to anything, never polls,
and never subscribes. It exists so the rest of the bridge's CRUD / config /
template / target plumbing has a single ``ServiceProvider`` to attach
self-monitoring trackers and notification slots to.
Events are constructed by the server-side helper
``services/bridge_self.emit_bridge_self_event`` and pushed into
``dispatch_provider_event`` directly the provider itself is not asked
to produce events.
"""
from __future__ import annotations
from typing import Any
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import (
ServiceProvider,
ServiceProviderType,
)
from notify_bridge_core.templates.variables import TemplateVariableDefinition
# Configuration keys recognised on the bridge_self provider's ``config`` JSON.
DEFAULT_POLL_FAILURE_THRESHOLD = 3
DEFAULT_DEFERRED_BACKLOG_THRESHOLD = 100
DEFAULT_TARGET_FAILURE_THRESHOLD = 5
# Template variables exposed to bridge_self templates.
BRIDGE_SELF_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="failure_type",
type="string",
description="Which self-monitoring condition fired",
example="poll_failures",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="subject_id",
type="int",
description="ID of the affected entity (tracker_id, target_id, or 0)",
example="42",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="subject_name",
type="string",
description="Human-readable name of the affected entity",
example="My Immich Tracker",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="count",
type="int",
description="Consecutive failure count or current backlog size",
example="3",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="threshold",
type="int",
description="Configured threshold that was crossed",
example="3",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="last_error",
type="string",
description="Last underlying error message (truncated)",
example="Connection refused",
provider_type=ServiceProviderType.BRIDGE_SELF,
),
TemplateVariableDefinition(
name="details",
type="dict",
description="Extra structured context for the event",
example='{"provider_id": 7}',
provider_type=ServiceProviderType.BRIDGE_SELF,
),
]
class BridgeSelfServiceProvider(ServiceProvider):
"""Passive provider — exposes nothing remote, holds only thresholds.
Polling is a no-op and ``connect`` always succeeds; the bridge itself
is what generates events for this provider.
"""
provider_type = ServiceProviderType.BRIDGE_SELF
supports_subscription = False
def __init__(self, name: str = "Bridge Self-Monitoring") -> None:
self._name = name
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
return None
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# No external service to poll. Returning empty keeps the contract
# so accidental scheduling no-ops cleanly.
return [], tracker_state
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(BRIDGE_SELF_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"poll_failure_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_POLL_FAILURE_THRESHOLD,
"description": "Consecutive tracker poll failures before alerting",
},
"deferred_backlog_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_DEFERRED_BACKLOG_THRESHOLD,
"description": "Pending deferred_dispatch rows before alerting",
},
"target_failure_threshold": {
"type": "integer",
"minimum": 1,
"default": DEFAULT_TARGET_FAILURE_THRESHOLD,
"description": "Consecutive target send failures before alerting",
},
},
"required": [],
}
async def list_collections(self) -> list[dict[str, Any]]:
# No collection concept — operators don't pick anything for this provider.
return []
async def test_connection(self) -> dict[str, Any]:
return {"ok": True, "message": "Bridge self-monitoring is always available"}
@@ -444,6 +444,133 @@ WEBHOOK_CAPABILITIES = ProviderCapabilities(
],
)
# ---------------------------------------------------------------------------
# Home Assistant provider capabilities
# ---------------------------------------------------------------------------
HOME_ASSISTANT_CAPABILITIES = ProviderCapabilities(
provider_type="home_assistant",
display_name="Home Assistant",
webhook_based=False,
supported_filters=[
{
"key": "collections",
"label": "Entities",
"type": "tags",
"placeholder": "light.kitchen",
},
{
"key": "entity_glob",
"label": "Entity glob",
"type": "tags",
"placeholder": "light.*",
},
{
"key": "domain_allowlist",
"label": "Domains",
"type": "tags",
"placeholder": "light, binary_sensor",
},
],
notification_slots=[
{"name": "message_ha_state_changed", "description": "Entity state changed"},
{"name": "message_ha_automation_triggered", "description": "Automation triggered"},
{"name": "message_ha_service_called", "description": "HA service called"},
{"name": "message_ha_event_fired", "description": "Other HA event fired"},
],
events=[
{"name": "ha_state_changed", "description": "Entity state changed"},
{"name": "ha_automation_triggered", "description": "Automation triggered"},
{"name": "ha_service_called", "description": "HA service called"},
{"name": "ha_event_fired", "description": "Other HA event fired (catch-all)"},
],
command_slots=[
# Response templates
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status connection summary"},
{"name": "entities", "description": "/entities matching glob"},
{"name": "state", "description": "/state single-entity drill-down"},
{"name": "areas", "description": "/areas with entity counts"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
# Description slots
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_entities", "description": "Menu description for /entities"},
{"name": "desc_state", "description": "Menu description for /state"},
{"name": "desc_areas", "description": "Menu description for /areas"},
# Usage examples
{"name": "usage_entities", "description": "Usage example for /entities"},
{"name": "usage_state", "description": "Usage example for /state"},
],
commands=[
{"name": "status", "description": "Show connection status"},
{"name": "entities", "description": "List entities (optional glob)"},
{"name": "state", "description": "Show state for one entity"},
{"name": "areas", "description": "List HA areas with entity counts"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Bridge self-monitoring capabilities
# ---------------------------------------------------------------------------
BRIDGE_SELF_CAPABILITIES = ProviderCapabilities(
provider_type="bridge_self",
display_name="Bridge Self-Monitoring",
webhook_based=False,
supported_filters=[],
notification_slots=[
{
"name": "message_bridge_self_poll_failures",
"description": "Tracker poll failures crossed threshold",
},
{
"name": "message_bridge_self_deferred_backlog",
"description": "Deferred dispatch backlog crossed threshold",
},
{
"name": "message_bridge_self_target_failures",
"description": "Target send failures crossed threshold",
},
],
events=[
{"name": "bridge_self_poll_failures", "description": "Tracker poll failures"},
{"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"},
{"name": "bridge_self_target_failures", "description": "Target send failures"},
],
command_slots=[
# Response templates
{"name": "start", "description": "/start greeting message"},
{"name": "help", "description": "/help command listing"},
{"name": "status", "description": "/status full counter snapshot"},
{"name": "thresholds", "description": "/thresholds configured alert thresholds"},
{"name": "reset", "description": "/reset manual counter reset"},
{"name": "health", "description": "/health terse one-line summary"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
# Description slots
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_thresholds", "description": "Menu description for /thresholds"},
{"name": "desc_reset", "description": "Menu description for /reset"},
{"name": "desc_health", "description": "Menu description for /health"},
# Usage examples
{"name": "usage_reset", "description": "Usage example for /reset"},
],
commands=[
{"name": "status", "description": "Show current bridge health counters"},
{"name": "thresholds", "description": "Show configured alert thresholds"},
{"name": "reset", "description": "Manually reset a failure counter"},
{"name": "health", "description": "Terse one-line health summary"},
{"name": "help", "description": "Show commands"},
],
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
@@ -456,6 +583,8 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
"nut": NUT_CAPABILITIES,
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
"webhook": WEBHOOK_CAPABILITIES,
"home_assistant": HOME_ASSISTANT_CAPABILITIES,
"bridge_self": BRIDGE_SELF_CAPABILITIES,
}
@@ -0,0 +1,34 @@
"""Home Assistant service provider implementation."""
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.templates.variables import registry
from .client import (
HomeAssistantApiError,
HomeAssistantAuthError,
HomeAssistantWSClient,
_redact as redact_ha_message,
)
from .event_parser import parse_event
from .provider import (
DEFAULT_HA_EVENT_TYPES,
HOME_ASSISTANT_VARIABLES,
HomeAssistantServiceProvider,
)
# Register HA variables in the global registry — same pattern as the other
# providers in this package.
registry.register_provider_variables(
ServiceProviderType.HOME_ASSISTANT, HOME_ASSISTANT_VARIABLES,
)
__all__ = [
"DEFAULT_HA_EVENT_TYPES",
"HOME_ASSISTANT_VARIABLES",
"HomeAssistantApiError",
"HomeAssistantAuthError",
"HomeAssistantServiceProvider",
"HomeAssistantWSClient",
"parse_event",
"redact_ha_message",
]
@@ -0,0 +1,506 @@
"""Home Assistant WebSocket client.
Implements the slice of the HA WebSocket API we need for Phase 1:
* Authenticate with a long-lived access token.
* Subscribe to events (optionally filtered by ``event_type``).
* Fetch the state list (``get_states``) for entity picker UI.
* Fetch the entity and area registries to build an ``entity_id -> area_id``
lookup that the parser uses to enrich ``state_changed`` events with the
area name.
* Run an indefinite subscription loop with exponential backoff reconnect.
The HA protocol reference is at
https://developers.home-assistant.io/docs/api/websocket/ message ids are
ascending integers, server replies use the same id, and authentication must
complete before any other command is accepted.
"""
from __future__ import annotations
import asyncio
import itertools
import logging
import random
import time
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Awaitable, Callable
from urllib.parse import urlparse, urlunparse
import aiohttp
_LOGGER = logging.getLogger(__name__)
class HomeAssistantAuthError(Exception):
"""Raised when HA rejects our access token. Fatal — no point retrying."""
class HomeAssistantApiError(Exception):
"""Raised when an HA WS command returns ``success: false``."""
# Default reconnect backoff: 2s, 4s, 8s, ..., capped at 60s with jitter.
_RECONNECT_BASE_SECONDS = 2.0
_RECONNECT_MAX_SECONDS = 60.0
_RECONNECT_JITTER_RATIO = 0.2
# Bounded queue between the WS receive loop and the emit consumer. Overflow
# drops the oldest event (FIFO) and logs at WARNING — better to lose one
# state_changed than fall behind the firehose indefinitely.
_EMIT_QUEUE_SIZE = 1000
def _ws_url_from_base(base_url: str) -> str:
"""Derive the HA WebSocket URL from the user-provided HTTP(S) base URL.
``http://homeassistant.local:8123`` -> ``ws://homeassistant.local:8123/api/websocket``.
The user enters their normal HA URL; we transform the scheme + append
the API path. This keeps the UI single-field and avoids confusion about
which URL form to use.
Userinfo (``user:pass@host``) is **stripped** credentials embedded in
the URL would otherwise flow into log lines and exception strings via
``aiohttp`` error messages. The HA WS protocol uses an access-token
handshake; HTTP basic auth in the URL is never the intended path.
"""
parsed = urlparse(base_url.rstrip("/"))
if parsed.scheme in ("ws", "wss"):
scheme = parsed.scheme
elif parsed.scheme == "https":
scheme = "wss"
else:
scheme = "ws"
# ``netloc`` may contain ``user:pass@host:port``; ``hostname`` + ``port``
# rebuild it without the credential prefix.
host = parsed.hostname or ""
if parsed.port is not None:
netloc = f"{host}:{parsed.port}"
else:
netloc = host
return urlunparse(
(scheme, netloc, "/api/websocket", "", "", "")
)
def _redact(text: str) -> str:
"""Strip embedded credentials from text before logging.
``aiohttp`` exception strings include the URL, so a malformed
``https://token@host`` would otherwise expose the token. This is a
defense-in-depth measure ``_ws_url_from_base`` already strips
userinfo from the connect URL, but third-party libs may quote the
user-supplied input separately.
"""
if not text:
return text
# Match ``scheme://[user[:pass]@]host`` and drop the userinfo segment.
import re
return re.sub(
r"(?P<scheme>\w+://)(?:[^/@\s]+@)",
r"\g<scheme>",
text,
)
class HomeAssistantWSClient:
"""Single-instance WebSocket client for one HA server."""
def __init__(
self,
session: aiohttp.ClientSession,
base_url: str,
access_token: str,
verify_tls: bool = True,
) -> None:
self._session = session
self._ws_url = _ws_url_from_base(base_url)
self._access_token = access_token
self._verify_tls = verify_tls
self._id_counter = itertools.count(1)
# ------------------------------------------------------------------
# Connection primitives
# ------------------------------------------------------------------
@asynccontextmanager
async def _connect(self) -> AsyncIterator[aiohttp.ClientWebSocketResponse]:
"""Open a fresh WS, complete the auth handshake, and yield the socket.
Raises :class:`HomeAssistantAuthError` on invalid token (fatal) and
:class:`HomeAssistantApiError` on other handshake failures (caller
decides whether to retry).
"""
ws = await self._session.ws_connect(
self._ws_url,
ssl=None if self._verify_tls else False,
heartbeat=30,
autoping=True,
)
try:
await self._authenticate(ws)
yield ws
finally:
await ws.close()
async def _authenticate(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Run the HA auth handshake on a freshly-opened socket."""
greeting = await ws.receive_json(timeout=10)
if greeting.get("type") != "auth_required":
raise HomeAssistantApiError(
f"Expected auth_required, got {greeting.get('type')!r}"
)
await ws.send_json({"type": "auth", "access_token": self._access_token})
result = await ws.receive_json(timeout=10)
msg_type = result.get("type")
if msg_type == "auth_ok":
return
if msg_type == "auth_invalid":
raise HomeAssistantAuthError(
result.get("message") or "Home Assistant rejected the access token"
)
raise HomeAssistantApiError(
f"Unexpected auth response: {msg_type!r}"
)
async def _send_command(
self,
ws: aiohttp.ClientWebSocketResponse,
payload: dict[str, Any],
) -> int:
"""Send a command with an auto-assigned id; return that id."""
msg_id = next(self._id_counter)
await ws.send_json({"id": msg_id, **payload})
return msg_id
async def _await_result(
self,
ws: aiohttp.ClientWebSocketResponse,
msg_id: int,
timeout: float = 15.0,
) -> Any:
"""Wait for a ``result`` message matching ``msg_id`` and return its payload.
``time.monotonic`` is the right clock here wall-clock deadlines
would jump on NTP sync, and ``asyncio.get_event_loop().time()``
is deprecated when called outside a running-loop context.
"""
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise HomeAssistantApiError(
f"Timed out waiting for result of command id={msg_id}"
)
msg = await ws.receive_json(timeout=remaining)
if msg.get("id") != msg_id:
# Ignore unsolicited events that arrive between sending a
# request-style command and its result.
continue
if msg.get("type") != "result":
continue
if not msg.get("success", False):
err = msg.get("error", {})
raise HomeAssistantApiError(
f"HA command failed: {err.get('code')} {err.get('message')}"
)
return msg.get("result")
# ------------------------------------------------------------------
# Multi-command session
# ------------------------------------------------------------------
@asynccontextmanager
async def session(self) -> AsyncIterator["HomeAssistantSession"]:
"""Open one authenticated WS and let the caller run multiple commands.
Each one-shot method (``get_states``, ``get_area_registry``, ...)
opens a brand-new connection with a full TCP + WS + auth handshake.
For callers that need to chain several queries (e.g. /status: connection
check + entity list + area count) that overhead adds up 3 separate
TLS handshakes and 3 auth round-trips for what is really one logical
request.
Usage:
async with client.session() as sess:
states = await sess.get_states()
areas = await sess.get_area_registry()
The session shares the same id counter as the client, so message ids
are unique across both one-shot calls and session-scoped calls if
they happen to run concurrently against the same client instance.
"""
async with self._connect() as ws:
yield HomeAssistantSession(self, ws)
# ------------------------------------------------------------------
# One-shot commands
# ------------------------------------------------------------------
async def test_connection(self) -> tuple[bool, str]:
"""Connect, authenticate, and immediately close. Returns ``(ok, message)``."""
try:
async with self._connect() as _ws:
return True, "OK"
except HomeAssistantAuthError as err:
return False, f"Auth failed: {err}"
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
return False, f"Connection failed: {err}"
except HomeAssistantApiError as err:
return False, str(err)
async def get_states(self) -> list[dict[str, Any]]:
"""Fetch the current state of every entity HA knows about."""
async with self._connect() as ws:
msg_id = await self._send_command(ws, {"type": "get_states"})
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_area_registry(self) -> list[dict[str, Any]]:
"""Fetch the area registry (``area_id`` -> name + metadata)."""
async with self._connect() as ws:
msg_id = await self._send_command(
ws, {"type": "config/area_registry/list"}
)
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_entity_registry(self) -> list[dict[str, Any]]:
"""Fetch the entity registry (entity_id -> area_id + metadata)."""
async with self._connect() as ws:
msg_id = await self._send_command(
ws, {"type": "config/entity_registry/list"}
)
result = await self._await_result(ws, msg_id)
return list(result or [])
async def get_entity_to_area_lookup(self) -> dict[str, str]:
"""Build ``{entity_id: area_name}`` using the entity + area registries.
Best-effort: returns an empty dict on any failure so the parser still
works without area enrichment.
"""
try:
entities = await self.get_entity_registry()
areas = await self.get_area_registry()
except (HomeAssistantApiError, aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Could not fetch HA registry, areas disabled: %s", err)
return {}
area_names = {a.get("area_id"): a.get("name") for a in areas if a.get("area_id")}
lookup: dict[str, str] = {}
for entry in entities:
entity_id = entry.get("entity_id")
area_id = entry.get("area_id")
if not isinstance(entity_id, str) or not area_id:
continue
name = area_names.get(area_id)
if name:
lookup[entity_id] = str(name)
return lookup
# ------------------------------------------------------------------
# Subscription loop with reconnect
# ------------------------------------------------------------------
async def run_subscription(
self,
on_event: Callable[[dict[str, Any]], Awaitable[None]],
event_types: list[str] | None = None,
on_status_change: Callable[[str, str | None], None] | None = None,
refresh_areas: Callable[[], Awaitable[dict[str, str]]] | None = None,
) -> None:
"""Run an indefinite subscription loop, reconnecting on drop.
Parameters
----------
on_event:
Coroutine called with the inner ``event`` dict (the WS envelope is
stripped). Slow callbacks apply TCP backpressure naturally; the
internal queue prevents unbounded memory growth if the callback
stalls.
event_types:
Restrict the subscription to these HA event types. ``None`` or
empty subscribes to everything (very loud only use for debug).
on_status_change:
Callback invoked with ``("connected", None)`` after a successful
handshake and ``("disconnected", reason)`` when a connection drops.
Useful for surfacing connection state in the event log.
refresh_areas:
Optional coroutine called on each (re)connect to refresh the
area lookup. The result is not used by ``run_subscription``
itself the caller stores it where its ``on_event`` can read.
"""
attempt = 0
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=_EMIT_QUEUE_SIZE)
overflow_count = 0
async def _drain() -> None:
while True:
evt = await queue.get()
try:
await on_event(evt)
except Exception: # noqa: BLE001
_LOGGER.exception("on_event callback raised; continuing")
finally:
queue.task_done()
drain_task = asyncio.create_task(_drain(), name="ha-emit-drain")
try:
while True:
try:
async with self._connect() as ws:
attempt = 0
if on_status_change is not None:
on_status_change("connected", None)
if refresh_areas is not None:
try:
# Note: refresh_areas opens its own WS in our
# current design (each one-shot command does).
# Fine for v1 — a few hundred ms once per
# (re)connect.
await refresh_areas()
except Exception: # noqa: BLE001
_LOGGER.exception("Area refresh failed; continuing without")
# Subscribe. Passing per-event-type subscriptions is
# cheaper than subscribing to everything and filtering
# in Python — HA does the filtering.
if event_types:
for evt_type in event_types:
sub_id = await self._send_command(
ws,
{"type": "subscribe_events", "event_type": evt_type},
)
await self._await_result(ws, sub_id)
else:
sub_id = await self._send_command(
ws, {"type": "subscribe_events"}
)
await self._await_result(ws, sub_id)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
payload = msg.json()
if payload.get("type") != "event":
continue
event_obj = payload.get("event")
if not isinstance(event_obj, dict):
continue
try:
queue.put_nowait(event_obj)
except asyncio.QueueFull:
overflow_count += 1
if overflow_count % 50 == 1:
_LOGGER.warning(
"HA event queue full, dropped %d events so far "
"(consumer is slower than HA event rate)",
overflow_count,
)
# Drop oldest, retry put. This keeps the
# most recent state visible at the cost
# of older transient changes.
try:
queue.get_nowait()
queue.task_done()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(event_obj)
except asyncio.QueueFull:
pass
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.ERROR,
):
raise aiohttp.ClientConnectionError(
f"WS closed: {msg.type.name}"
)
else:
# PING/PONG handled by aiohttp autoping=True;
# BINARY/CONTINUATION are not used by HA today.
# Log at debug so a future protocol change is
# visible without spamming production logs.
_LOGGER.debug(
"Ignored WS message of type %s", msg.type.name,
)
except HomeAssistantAuthError as err:
# Fatal — caller must fix the access token. Reraise so
# the provider can mark itself unhealthy.
if on_status_change is not None:
on_status_change("disconnected", _redact(f"auth: {err}"))
raise
except asyncio.CancelledError:
if on_status_change is not None:
on_status_change("disconnected", "cancelled")
raise
except Exception as err: # noqa: BLE001
redacted = _redact(str(err))
if on_status_change is not None:
on_status_change("disconnected", redacted)
delay = min(
_RECONNECT_BASE_SECONDS * (2 ** attempt),
_RECONNECT_MAX_SECONDS,
)
delay *= 1 + random.uniform(-_RECONNECT_JITTER_RATIO, _RECONNECT_JITTER_RATIO)
_LOGGER.warning(
"HA WS connection lost (%s); reconnecting in %.1fs",
redacted, delay,
)
attempt = min(attempt + 1, 10)
await asyncio.sleep(delay)
finally:
drain_task.cancel()
# Drain task may finish via CancelledError (normal) or via an
# unhandled exception thrown by on_event. Either way is fine here
# — we're tearing down. Split the two cases for clarity rather
# than catching `Exception` + `CancelledError` in one clause.
try:
await drain_task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001
_LOGGER.exception("HA drain task raised during shutdown")
# ---------------------------------------------------------------------------
# Multi-command session
# ---------------------------------------------------------------------------
class HomeAssistantSession:
"""A multi-command HA WS session bound to a single authenticated socket.
Created via :meth:`HomeAssistantWSClient.session`. Use when you need to
issue several commands in a row sharing the connection saves the TCP
+ WS + auth round trips for every command after the first.
The session forwards id assignment to the parent client's monotonic
counter so ids stay unique across all sessions sharing the same client.
"""
def __init__(
self,
client: HomeAssistantWSClient,
ws: aiohttp.ClientWebSocketResponse,
) -> None:
self._client = client
self._ws = ws
async def send(self, payload: dict[str, Any], timeout: float = 15.0) -> Any:
"""Send one command and wait for its ``result`` envelope."""
msg_id = await self._client._send_command(self._ws, payload)
return await self._client._await_result(self._ws, msg_id, timeout=timeout)
async def get_states(self) -> list[dict[str, Any]]:
result = await self.send({"type": "get_states"})
return list(result or [])
async def get_area_registry(self) -> list[dict[str, Any]]:
result = await self.send({"type": "config/area_registry/list"})
return list(result or [])
async def get_entity_registry(self) -> list[dict[str, Any]]:
result = await self.send({"type": "config/entity_registry/list"})
return list(result or [])
@@ -0,0 +1,267 @@
"""Home Assistant event parser — HA WebSocket event dict -> ServiceEvent.
The HA event bus delivers events with this envelope:
.. code-block:: json
{
"id": 7,
"type": "event",
"event": {
"event_type": "state_changed",
"data": { ... event-type-specific ... },
"origin": "LOCAL",
"time_fired": "2026-05-13T12:34:56.789Z",
"context": { ... }
}
}
The parser accepts the inner ``event`` dict (the WS client strips the outer
envelope before calling us) and emits a :class:`ServiceEvent` ready for the
existing dispatch path. Areas are looked up via an optional ``area_lookup``
mapping so the parser stays pure the WS client maintains the registry
cache and passes its current snapshot on each call.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType
_LOGGER = logging.getLogger(__name__)
# Defensive caps for fields that get persisted to the event_log row. Home
# Assistant's own constraints keep entity ids well under 70 chars, but a
# misbehaving custom integration could emit kilobyte-sized strings that
# would bloat the JSON details column.
_MAX_ENTITY_ID_LEN = 255
_MAX_EVENT_DATA_BYTES = 4096
def _parse_time_fired(raw: Any) -> datetime:
"""Parse HA's ``time_fired`` ISO string, falling back to now() on garbage.
HA always sends UTC with a ``Z`` suffix or explicit ``+00:00``. Datetime
parsing is wrapped because a malformed payload should not break the
pipeline better to dispatch with a slightly-off timestamp than drop.
"""
if isinstance(raw, str):
try:
# ``datetime.fromisoformat`` accepts ``+00:00`` natively; rewrite
# the trailing ``Z`` since pre-3.11 stdlib rejects it.
cleaned = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
return datetime.fromisoformat(cleaned)
except ValueError:
_LOGGER.debug("Unparseable HA time_fired %r, using now()", raw)
return datetime.now(timezone.utc)
def _domain_of(entity_id: str) -> str:
"""Return the HA domain prefix (``light.kitchen`` -> ``light``)."""
if "." in entity_id:
return entity_id.split(".", 1)[0]
return ""
def _friendly_name(state_obj: dict[str, Any] | None, entity_id: str) -> str:
"""Pull ``friendly_name`` from attributes or fall back to entity_id."""
if not state_obj:
return entity_id
attrs = state_obj.get("attributes") or {}
name = attrs.get("friendly_name")
return str(name) if name else entity_id
def parse_event(
ha_event: dict[str, Any],
provider_name: str,
area_lookup: dict[str, str] | None = None,
) -> ServiceEvent | None:
"""Parse one HA event dict into a :class:`ServiceEvent`.
Returns None for malformed payloads (missing ``event_type`` etc.) so the
caller can drop without raising. Genuine network/parsing exceptions
bubble up only known-bad payload shapes return None.
"""
if not isinstance(ha_event, dict):
return None
event_type_raw = ha_event.get("event_type")
if not isinstance(event_type_raw, str):
return None
data = ha_event.get("data") or {}
timestamp = _parse_time_fired(ha_event.get("time_fired"))
area_lookup = area_lookup or {}
if event_type_raw == "state_changed":
return _parse_state_changed(data, timestamp, provider_name, area_lookup)
if event_type_raw == "automation_triggered":
return _parse_automation_triggered(data, timestamp, provider_name)
if event_type_raw == "call_service":
return _parse_call_service(data, timestamp, provider_name)
# Everything else maps to the generic "event_fired" slot. Tracking
# configs decide whether to enable this loud catch-all.
return _parse_generic_event(event_type_raw, data, timestamp, provider_name)
def _parse_state_changed(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
area_lookup: dict[str, str],
) -> ServiceEvent | None:
entity_id = data.get("entity_id")
if not isinstance(entity_id, str):
return None
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
old_state_obj = data.get("old_state") if isinstance(data.get("old_state"), dict) else None
new_state_obj = data.get("new_state") if isinstance(data.get("new_state"), dict) else None
# ``new_state`` is None when an entity is removed — surface it as a
# transition to the literal string "removed" so templates can branch.
old_state_val = old_state_obj.get("state") if old_state_obj else None
new_state_val = new_state_obj.get("state") if new_state_obj else "removed"
attributes = (new_state_obj or {}).get("attributes") or {}
friendly_name = _friendly_name(new_state_obj or old_state_obj, entity_id)
domain = _domain_of(entity_id)
extra: dict[str, Any] = {
"entity_id": entity_id,
"friendly_name": friendly_name,
"domain": domain,
"old_state": old_state_val,
"new_state": new_state_val,
"attributes": attributes,
"device_class": attributes.get("device_class"),
"unit_of_measurement": attributes.get("unit_of_measurement"),
"area": area_lookup.get(entity_id),
"ha_event_type": "state_changed",
}
if new_state_obj and "last_changed" in new_state_obj:
extra["last_changed"] = new_state_obj["last_changed"]
if new_state_obj and "last_updated" in new_state_obj:
extra["last_updated"] = new_state_obj["last_updated"]
return ServiceEvent(
event_type=EventType.HA_STATE_CHANGED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=entity_id,
collection_name=friendly_name,
timestamp=timestamp,
extra=extra,
)
def _parse_automation_triggered(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
entity_id = data.get("entity_id")
if isinstance(entity_id, str):
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
automation_name = data.get("name") or (entity_id if isinstance(entity_id, str) else "automation")
source = data.get("source") or ""
collection_id = entity_id if isinstance(entity_id, str) else f"automation.{automation_name}"
collection_id = collection_id[:_MAX_ENTITY_ID_LEN]
return ServiceEvent(
event_type=EventType.HA_AUTOMATION_TRIGGERED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=collection_id,
collection_name=str(automation_name),
timestamp=timestamp,
extra={
"entity_id": entity_id,
"automation_name": str(automation_name),
"trigger_source": str(source),
"ha_event_type": "automation_triggered",
},
)
def _parse_call_service(
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
domain = data.get("domain")
service = data.get("service")
if not isinstance(domain, str) or not isinstance(service, str):
return None
domain = domain[:_MAX_ENTITY_ID_LEN]
service = service[:_MAX_ENTITY_ID_LEN]
service_data = data.get("service_data") if isinstance(data.get("service_data"), dict) else {}
qualified = f"{domain}.{service}"
target_entity = None
if isinstance(service_data, dict):
raw_target = service_data.get("entity_id")
if isinstance(raw_target, str):
target_entity = raw_target
elif isinstance(raw_target, list) and raw_target:
target_entity = ", ".join(str(x) for x in raw_target)
return ServiceEvent(
event_type=EventType.HA_SERVICE_CALLED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=qualified,
collection_name=qualified,
timestamp=timestamp,
extra={
"service_domain": domain,
"service_name": service,
"service_called": qualified,
"service_data": service_data,
"target_entity": target_entity,
"ha_event_type": "call_service",
},
)
def _parse_generic_event(
event_type_raw: str,
data: dict[str, Any],
timestamp: datetime,
provider_name: str,
) -> ServiceEvent | None:
event_type_raw = event_type_raw[:_MAX_ENTITY_ID_LEN]
# Cap the serialized payload so a custom HA integration that emits
# a multi-megabyte event_data dict doesn't blow up the event_log JSON
# column. Templates can still reference fields up to the cap; beyond it
# the dict is replaced with a marker so the limit is visible to authors.
capped_data: Any = data
try:
serialized = json.dumps(data, default=str)
except (TypeError, ValueError):
# Unserializable payload — keep the dict in-memory so templates can
# still read scalar fields, but flag the size as 0 to avoid surprises.
serialized = ""
if len(serialized.encode("utf-8")) > _MAX_EVENT_DATA_BYTES:
capped_data = {
"_truncated": True,
"_original_size_bytes": len(serialized.encode("utf-8")),
"_note": f"event_data exceeded {_MAX_EVENT_DATA_BYTES}B and was dropped",
}
return ServiceEvent(
event_type=EventType.HA_EVENT_FIRED,
provider_type=ServiceProviderType.HOME_ASSISTANT,
provider_name=provider_name,
collection_id=event_type_raw,
collection_name=event_type_raw,
timestamp=timestamp,
extra={
"ha_event_type": event_type_raw,
"event_data": capped_data,
},
)
@@ -0,0 +1,323 @@
"""Home Assistant service provider — WebSocket subscription based.
Unlike polling providers (Immich, NUT, Google Photos) and webhook providers
(Gitea, Planka), the HA provider maintains a long-lived WebSocket connection
to the HA server and pushes events into the dispatch pipeline as they
arrive. The lifecycle is owned by the server-side subscription manager
(see ``services/ha_subscription.py``).
"""
from __future__ import annotations
import logging
from typing import Any, Callable
import aiohttp
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import (
EventEmitCallback,
ServiceProvider,
ServiceProviderType,
)
from notify_bridge_core.templates.variables import TemplateVariableDefinition
from .client import HomeAssistantWSClient
from .event_parser import parse_event
# Status callback signature: ``(state, detail)`` where ``state`` is one of
# ``"connected"`` / ``"disconnected"`` and ``detail`` is an optional already-
# redacted reason string (or None on connect).
StatusChangeCallback = Callable[[str, str | None], None]
_LOGGER = logging.getLogger(__name__)
# Home Assistant template variables exposed to Jinja2.
HOME_ASSISTANT_VARIABLES: list[TemplateVariableDefinition] = [
TemplateVariableDefinition(
name="entity_id",
type="string",
description="HA entity id (e.g. light.kitchen)",
example="light.kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="friendly_name",
type="string",
description="Human-readable entity name from attributes.friendly_name",
example="Kitchen Light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="domain",
type="string",
description="HA domain prefix of the entity (e.g. light, sensor, binary_sensor)",
example="light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="old_state",
type="string",
description="Previous state string before the change",
example="off",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="new_state",
type="string",
description="New state string (literal 'removed' when entity was deleted)",
example="on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="attributes",
type="dict",
description="Full attributes dict of the new state",
example='{"brightness": 255, "color_mode": "brightness"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="device_class",
type="string",
description="Device class from attributes (motion, door, temperature, ...)",
example="motion",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="unit_of_measurement",
type="string",
description="Unit suffix for numeric sensors",
example="°C",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="area",
type="string",
description="Area name from the HA area registry (empty when not assigned)",
example="Kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="last_changed",
type="string",
description="ISO timestamp of last state change",
example="2026-05-13T12:34:56.789+00:00",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="last_updated",
type="string",
description="ISO timestamp of last attribute or state update",
example="2026-05-13T12:34:56.789+00:00",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="automation_name",
type="string",
description="Automation name (automation_triggered events)",
example="Front Door Notification",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="trigger_source",
type="string",
description="Why an automation fired (automation_triggered events)",
example="state of binary_sensor.front_door",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_called",
type="string",
description="Qualified service name (call_service events)",
example="light.turn_on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_domain",
type="string",
description="Service domain (call_service events)",
example="light",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_name",
type="string",
description="Service name within domain (call_service events)",
example="turn_on",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="service_data",
type="dict",
description="Service payload (call_service events)",
example='{"entity_id": "light.kitchen"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="target_entity",
type="string",
description="entity_id targeted by a service call (comma-joined for multi-target)",
example="light.kitchen",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="ha_event_type",
type="string",
description="Raw HA event_type (state_changed, automation_triggered, ...)",
example="state_changed",
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
TemplateVariableDefinition(
name="event_data",
type="dict",
description="Raw event data (generic event_fired events)",
example='{"key": "value"}',
provider_type=ServiceProviderType.HOME_ASSISTANT,
),
]
# Default event types subscribed to when the user does not override. Only
# state_changed is on by default — the others are loud and opt-in via the
# tracking-config event checkboxes.
DEFAULT_HA_EVENT_TYPES: tuple[str, ...] = ("state_changed",)
class HomeAssistantServiceProvider(ServiceProvider):
"""Home Assistant WebSocket subscription provider."""
provider_type = ServiceProviderType.HOME_ASSISTANT
supports_subscription = True
def __init__(
self,
session: aiohttp.ClientSession,
url: str,
access_token: str,
verify_tls: bool = True,
event_types: list[str] | None = None,
name: str = "Home Assistant",
) -> None:
self._client = HomeAssistantWSClient(
session=session,
base_url=url,
access_token=access_token,
verify_tls=verify_tls,
)
self._name = name
self._event_types = list(event_types) if event_types else list(DEFAULT_HA_EVENT_TYPES)
# ``_area_lookup`` is refreshed on every (re)connect by run_subscription's
# ``refresh_areas`` hook so the parser can enrich state_changed events
# with the current area name.
self._area_lookup: dict[str, str] = {}
@property
def client(self) -> HomeAssistantWSClient:
return self._client
async def connect(self) -> bool:
ok, _ = await self._client.test_connection()
return ok
async def disconnect(self) -> None:
# Session lifecycle is managed by the caller; the WS connection is
# owned by run_subscription which exits on cancel.
return None
async def poll(
self,
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
# Subscription-based ingest. The polling scheduler MUST NOT call us
# — the subscription manager owns this provider's lifecycle instead.
return [], tracker_state
async def subscribe(
self,
emit: EventEmitCallback,
on_status_change: StatusChangeCallback | None = None,
) -> None:
async def _on_event(ha_event: dict[str, Any]) -> None:
event = parse_event(
ha_event,
provider_name=self._name,
area_lookup=self._area_lookup,
)
if event is None:
return
await emit(event)
async def _refresh_areas() -> dict[str, str]:
try:
self._area_lookup = await self._client.get_entity_to_area_lookup()
except Exception: # noqa: BLE001
# Best-effort: keep the previous lookup on failure.
_LOGGER.exception("Failed to refresh HA area lookup")
return self._area_lookup
await self._client.run_subscription(
on_event=_on_event,
event_types=self._event_types,
refresh_areas=_refresh_areas,
on_status_change=on_status_change,
)
def get_available_variables(self) -> list[TemplateVariableDefinition]:
return list(HOME_ASSISTANT_VARIABLES)
def get_provider_config_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Home Assistant base URL (http://homeassistant.local:8123)",
"example": "http://homeassistant.local:8123",
},
"access_token": {
"type": "string",
"description": "Long-lived access token (HA Profile -> Long-Lived Access Tokens)",
"secret": True,
},
"verify_tls": {
"type": "boolean",
"description": "Validate TLS certificate. Disable only for self-signed HA setups on trusted networks.",
"default": True,
},
"event_types": {
"type": "array",
"items": {"type": "string"},
"description": "HA event types to subscribe to. Defaults to ['state_changed'].",
"default": list(DEFAULT_HA_EVENT_TYPES),
},
},
"required": ["url", "access_token"],
}
async def list_collections(self) -> list[dict[str, Any]]:
"""Return the current entity list for the entity-picker UI."""
try:
states = await self._client.get_states()
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not fetch HA states: %s", err)
return []
out: list[dict[str, Any]] = []
for state in states:
entity_id = state.get("entity_id")
if not isinstance(entity_id, str):
continue
attrs = state.get("attributes") or {}
out.append({
"id": entity_id,
"name": attrs.get("friendly_name") or entity_id,
"state": state.get("state"),
"domain": entity_id.split(".", 1)[0] if "." in entity_id else "",
})
return out
async def test_connection(self) -> dict[str, Any]:
ok, message = await self._client.test_connection()
return {"ok": ok, "message": message}
@@ -29,10 +29,21 @@ _LOGGER = logging.getLogger(__name__)
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
# raw api_key out of dict keys in case of a memory dump.
_USERS_CACHE_TTL_SECONDS = 3600
_users_cache_lock = asyncio.Lock()
# Lazy init: ``asyncio.Lock()`` at module import binds to whichever event
# loop is current at import time (often none, or the wrong one when tests
# spin up dedicated loops). Defer creation to first use.
_users_cache_lock: asyncio.Lock | None = None
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _get_users_cache_lock() -> asyncio.Lock:
"""Return the module users-cache lock, creating it on first call."""
global _users_cache_lock
if _users_cache_lock is None:
_users_cache_lock = asyncio.Lock()
return _users_cache_lock
def _users_cache_key(url: str, api_key: str) -> str:
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
return digest[:32]
@@ -51,7 +62,7 @@ async def _get_cached_users(
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
return entry[1]
async with _users_cache_lock:
async with _get_users_cache_lock():
# Re-check after acquiring the lock — another coroutine may have
# refreshed the entry while we waited.
entry = _users_cache.get(key)
@@ -200,10 +200,28 @@ class NutServiceProvider(ServiceProvider):
try:
for ups_name in collection_ids:
prev = tracker_state.get(ups_name, {})
# First-ever observation has no baseline — emitting transition
# events for whatever flags the device happens to carry would
# spam the user with "OB"/"LB"/"REPLBATT" alerts on every fresh
# tracker even when nothing changed. Seed state silently and
# skip event emission until the next poll provides a baseline.
is_first_observation = ups_name not in tracker_state
try:
variables = await client.list_var(ups_name)
data = NutUpsData.from_variables(ups_name, variables)
if is_first_observation:
new_state[ups_name] = {
"name": data.description or ups_name,
"status": data.status,
"battery_charge": data.battery_charge,
"comms_ok": True,
"asset_ids": [],
"pending_asset_ids": [],
"shared": False,
}
continue
# Check for comms restored
if not prev.get("comms_ok", True):
events.append(self._make_event(
@@ -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
@@ -0,0 +1 @@
Terse one-line health summary
@@ -0,0 +1 @@
Show available commands
@@ -0,0 +1 @@
Reset a failure counter (tracker:&lt;id&gt;, target:&lt;id&gt;, or all)
@@ -0,0 +1 @@
Show current bridge health counters
@@ -0,0 +1 @@
Show configured alert thresholds
@@ -0,0 +1,5 @@
{%- if healthy -%}
✅ {{ summary }}
{%- else -%}
🚨 {{ summary }}
{%- endif %}
@@ -0,0 +1,5 @@
🩺 <b>Available commands:</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
⏳ Too many requests. Please wait {{ wait }}s before trying again.
@@ -0,0 +1,14 @@
{%- if success %}
✅ <b>Counter reset</b>
{%- if subject_type == 'all' %}
Cleared {{ previous_count }} of your failure counters (trackers + targets).
{%- else %}
{{ subject_type|capitalize }} <b>{{ subject_name }}</b>{% if subject_id %} (id <code>{{ subject_id }}</code>){% endif %}
Previous count: <b>{{ previous_count }}</b> → 0
{%- endif %}
{%- else %}
❌ <b>Reset failed</b>
{%- if error_message %}
<i>{{ error_message }}</i>
{%- endif %}
{%- endif %}
@@ -0,0 +1,2 @@
👋 Hi! I'm your Notify Bridge bot for <b>Bridge Self-Monitoring</b>.
Use /help to see available commands.
@@ -0,0 +1,28 @@
🩺 <b>Bridge Status</b>
{%- if poll_failures %}
🚨 <b>Tracker poll failures</b>
{%- for f in poll_failures %}
• <b>{{ f.tracker_name }}</b> (id <code>{{ f.tracker_id }}</code>) — {{ f.count }} consecutive
{%- endfor %}
{%- endif %}
{%- if deferred_pending is none %}
⏳ <b>Deferred backlog</b>
Pending: <b>unknown</b> (DB unavailable) · Threshold: {{ deferred_threshold }}
{%- elif deferred_pending %}
⏳ <b>Deferred backlog</b>
Pending: <b>{{ deferred_pending }}</b> · Threshold: {{ deferred_threshold }}
{%- endif %}
{%- if target_failures %}
📡 <b>Target send failures</b>
{%- for f in target_failures %}
• <b>{{ f.target_name }}</b> (id <code>{{ f.target_id }}</code>) — {{ f.count }} consecutive
{%- endfor %}
{%- endif %}
{%- if not poll_failures and not target_failures and deferred_pending == 0 %}
✅ All counters at zero. Nothing to report.
{%- endif %}
@@ -0,0 +1,4 @@
⚙️ <b>Bridge Thresholds</b>
Tracker poll failures: <b>{{ poll_failure_threshold }}</b>
Deferred backlog: <b>{{ deferred_backlog_threshold }}</b>
Target send failures: <b>{{ target_failure_threshold }}</b>
@@ -0,0 +1 @@
/reset tracker:42 (or target:&lt;id&gt;, or all)
@@ -0,0 +1,9 @@
🗺️ <b>Areas</b>
{%- if areas %}
{%- for a in areas %}
<b>{{ a.name }}</b> — {{ a.entity_count }} entity(ies)
{%- endfor %}
<i>Total: {{ total }}</i>
{%- else %}
No areas configured in Home Assistant.
{%- endif %}
@@ -0,0 +1 @@
List HA areas with entity counts
@@ -0,0 +1 @@
List entities (optional glob)
@@ -0,0 +1 @@
Show full state for one entity

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