Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a8f1e71c | |||
| 2d59a5b994 | |||
| a20635a657 | |||
| d7c48b06ee | |||
| 66f152ef2c | |||
| faaaa39f8a | |||
| 8651767112 | |||
| 10d30fc956 | |||
| 22127e2a59 | |||
| 90f958bdc6 | |||
| dec0839853 |
@@ -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.
|
||||
@@ -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) | 2–3 sessions |
|
||||
| Phase 2 (bot commands) | 1 session |
|
||||
| Phase 3 (smart actions) | 1–2 sessions |
|
||||
|
||||
## When to start
|
||||
|
||||
Phase 1 work order, once you green-light it:
|
||||
|
||||
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
|
||||
provider.
|
||||
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
|
||||
needed for these).
|
||||
3. Subscription manager in `services/watcher.py` — integration test with the
|
||||
fake provider from step 1.
|
||||
4. Templates (en + ru), capabilities entry, validator whitelist.
|
||||
5. Server: seeds, sample context, template_configs entry.
|
||||
6. Frontend: descriptor, locale keys, i18n.
|
||||
7. End-to-end smoke test against a real HA instance (homelab).
|
||||
|
||||
Backend restart cadence per the project rule: after **every** change in
|
||||
`packages/server/` or `packages/core/`.
|
||||
|
||||
## Decision log
|
||||
|
||||
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
|
||||
webhook for future-proofing toward Phases 2 + 3). Not started.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
+34
-19
@@ -1,41 +1,56 @@
|
||||
# v0.8.0 (2026-05-12)
|
||||
# v0.8.2 (2026-05-22)
|
||||
|
||||
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.
|
||||
|
||||
## User-facing changes
|
||||
|
||||
### Features
|
||||
### Security
|
||||
|
||||
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **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))
|
||||
|
||||
### Documentation
|
||||
### Bug Fixes
|
||||
|
||||
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
|
||||
- **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
|
||||
|
||||
### Architecture
|
||||
### Refactoring
|
||||
|
||||
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **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))
|
||||
|
||||
### Tests
|
||||
### Chores
|
||||
|
||||
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
||||
- **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 |
|
||||
|------|---------|--------|
|
||||
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
|
||||
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
|
||||
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | alexei.dolgolyov |
|
||||
- [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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -505,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
@@ -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) {
|
||||
|
||||
@@ -240,34 +240,42 @@
|
||||
{/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 displayEvent.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
|
||||
{@const providerId = displayEvent.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', providerId)}>
|
||||
<MdiIcon name="mdiServer" size={14} />
|
||||
{t('events.openProvider')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.telegram_bot_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
|
||||
{@const botId = displayEvent.telegram_bot_id}
|
||||
<button type="button" onclick={() => openEntity('/bots', botId)}>
|
||||
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||
{t('events.openBot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.command_tracker_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
|
||||
{@const cmdTrackerId = displayEvent.command_tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if displayEvent.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
|
||||
{@const actionId = displayEvent.action_id}
|
||||
<button type="button" onclick={() => openEntity('/actions', actionId)}>
|
||||
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||
{t('events.openAction')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCommand && !isAction && displayEvent.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', 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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]};">
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -157,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",
|
||||
@@ -232,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",
|
||||
@@ -317,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)",
|
||||
@@ -641,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",
|
||||
@@ -1095,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…",
|
||||
@@ -1177,6 +1222,7 @@
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"auto": "Auto",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
@@ -1318,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",
|
||||
@@ -1340,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",
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
"eventsLabel": "событий",
|
||||
"onWatchTitle": "На",
|
||||
"onWatchEmphasis": "слежении",
|
||||
"statsModeTitle": "Область статистики провайдеров",
|
||||
"statsModePage": "Страница",
|
||||
"statsModeAll": "Все",
|
||||
"noProviders": "Пока нет провайдеров.",
|
||||
"addProvider": "Добавить",
|
||||
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
||||
@@ -232,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": "необязательно",
|
||||
@@ -317,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": "Интервал проверки (секунды)",
|
||||
@@ -641,6 +665,14 @@
|
||||
"upsOverload": "Перегрузка ИБП",
|
||||
"scheduledMessage": "Запланированное сообщение",
|
||||
"webhookReceived": "Вебхук получен",
|
||||
"haStateChanged": "Состояние сущности изменилось",
|
||||
"haAutomationTriggered": "Сработала автоматизация",
|
||||
"haServiceCalled": "Вызвана служба",
|
||||
"haEventFired": "Прочее событие HA (catch-all)",
|
||||
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||
"bridgeSelfPollFailures": "Сбои опроса трекера",
|
||||
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
|
||||
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
@@ -1095,9 +1127,22 @@
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Состояние моста",
|
||||
"statusDesc": "Показать счётчики состояния моста",
|
||||
"thresholds": "Пороги моста",
|
||||
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||
"reset": "Сбросить счётчик",
|
||||
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||
"health": "Здоровье моста",
|
||||
"healthDesc": "Краткая однострочная сводка состояния"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
"hideDetails": "Скрыть детали",
|
||||
"region": "Уведомления"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||
@@ -1177,6 +1222,7 @@
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"auto": "Авто",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
@@ -1318,6 +1364,8 @@
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"statsModePage": "Учитывать только события на текущей странице",
|
||||
"statsModeAll": "Учитывать все события под текущими фильтрами",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1340,7 +1388,9 @@
|
||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||
"providerNut": "Мониторинг ИБП через NUT",
|
||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||
"providerWebhook": "Приём событий через HTTP POST"
|
||||
"providerWebhook": "Приём событий через HTTP POST",
|
||||
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
|
||||
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Последние запросы",
|
||||
|
||||
@@ -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(' · ');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -372,6 +372,10 @@ 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';
|
||||
|
||||
@@ -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';
|
||||
@@ -46,30 +46,40 @@
|
||||
{ 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);
|
||||
@@ -91,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
|
||||
@@ -446,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}
|
||||
@@ -493,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>
|
||||
@@ -512,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>
|
||||
@@ -595,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} />
|
||||
@@ -609,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">
|
||||
|
||||
@@ -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;
|
||||
@@ -812,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}
|
||||
|
||||
@@ -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';
|
||||
@@ -99,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(); }
|
||||
}
|
||||
|
||||
@@ -138,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;
|
||||
}
|
||||
|
||||
@@ -151,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) {
|
||||
@@ -165,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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -89,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; }
|
||||
}
|
||||
|
||||
@@ -99,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; }
|
||||
}
|
||||
@@ -115,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -85,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; }
|
||||
}
|
||||
|
||||
@@ -95,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; }
|
||||
}
|
||||
@@ -111,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -105,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; }
|
||||
}
|
||||
|
||||
@@ -115,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; }
|
||||
}
|
||||
@@ -147,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 };
|
||||
}
|
||||
|
||||
@@ -156,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: 'Українська' },
|
||||
@@ -177,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 {
|
||||
@@ -189,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) {
|
||||
@@ -202,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) {
|
||||
@@ -232,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) {
|
||||
@@ -241,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 };
|
||||
}
|
||||
|
||||
@@ -254,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 };
|
||||
}
|
||||
|
||||
@@ -274,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 };
|
||||
}
|
||||
|
||||
@@ -284,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 };
|
||||
}
|
||||
|
||||
@@ -315,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 };
|
||||
}
|
||||
|
||||
@@ -464,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)}>
|
||||
@@ -511,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';
|
||||
@@ -105,7 +105,7 @@
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -214,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; }
|
||||
}
|
||||
|
||||
@@ -227,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; }
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -213,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;
|
||||
@@ -354,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +408,8 @@
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,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;
|
||||
}
|
||||
@@ -587,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -107,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(); }
|
||||
}
|
||||
|
||||
@@ -168,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; }
|
||||
}
|
||||
|
||||
@@ -180,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; }
|
||||
}
|
||||
};
|
||||
@@ -193,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 };
|
||||
}
|
||||
|
||||
@@ -226,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 };
|
||||
}
|
||||
|
||||
@@ -235,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
|
||||
@@ -264,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 {
|
||||
@@ -398,7 +398,7 @@
|
||||
<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>
|
||||
@@ -473,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')}
|
||||
|
||||
@@ -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';
|
||||
@@ -166,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(); }
|
||||
}
|
||||
@@ -289,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() {
|
||||
@@ -301,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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,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; }
|
||||
@@ -335,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;
|
||||
}
|
||||
|
||||
@@ -451,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 };
|
||||
}
|
||||
|
||||
@@ -460,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) {
|
||||
@@ -470,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) {
|
||||
@@ -492,8 +492,8 @@
|
||||
} else {
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -169,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) {
|
||||
@@ -237,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;
|
||||
}
|
||||
|
||||
@@ -248,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>
|
||||
@@ -321,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)}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -26,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) {
|
||||
@@ -74,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];
|
||||
@@ -142,7 +142,7 @@
|
||||
return tiles;
|
||||
}
|
||||
|
||||
// ── Derived state ──
|
||||
// ──── Derived state ────
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
@@ -158,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);
|
||||
@@ -204,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>>({});
|
||||
@@ -228,7 +228,7 @@
|
||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||
}
|
||||
|
||||
// ── Effects ──
|
||||
// ──── Effects ────
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
$effect(() => {
|
||||
@@ -239,11 +239,11 @@
|
||||
addingReceiverForTarget = null;
|
||||
});
|
||||
|
||||
// ── Data loading ──
|
||||
// ──── Data loading ────
|
||||
|
||||
onMount(load);
|
||||
|
||||
// ── Bot grouping ──
|
||||
// ──── Bot grouping ────
|
||||
|
||||
type TargetGroup = {
|
||||
key: string;
|
||||
@@ -355,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;
|
||||
@@ -382,7 +382,7 @@
|
||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||
}
|
||||
|
||||
// ── Target CRUD ──
|
||||
// ──── Target CRUD ────
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
@@ -475,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;
|
||||
}
|
||||
@@ -488,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);
|
||||
@@ -497,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
|
||||
@@ -575,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;
|
||||
}
|
||||
@@ -590,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) {
|
||||
@@ -598,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) {
|
||||
@@ -613,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) {
|
||||
@@ -622,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -261,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(); }
|
||||
}
|
||||
|
||||
@@ -347,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); }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,8 +404,8 @@
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,10 +472,10 @@
|
||||
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; }
|
||||
}
|
||||
@@ -626,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>
|
||||
|
||||
@@ -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';
|
||||
@@ -158,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;
|
||||
}
|
||||
@@ -217,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(); }
|
||||
}
|
||||
|
||||
@@ -285,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);
|
||||
@@ -294,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; }
|
||||
}
|
||||
|
||||
@@ -1,227 +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 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: 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); }
|
||||
}
|
||||
|
||||
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} />
|
||||
<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} />
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.8.0"
|
||||
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(
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
Terse one-line health summary
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Reset a failure counter (tracker:<id>, target:<id>, or all)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show current bridge health counters
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show configured alert thresholds
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{%- if healthy -%}
|
||||
✅ {{ summary }}
|
||||
{%- else -%}
|
||||
🚨 {{ summary }}
|
||||
{%- endif %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
🩺 <b>Available commands:</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Too many requests. Please wait {{ wait }}s before trying again.
|
||||
+14
@@ -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 %}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
👋 Hi! I'm your Notify Bridge bot for <b>Bridge Self-Monitoring</b>.
|
||||
Use /help to see available commands.
|
||||
+28
@@ -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 %}
|
||||
+4
@@ -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>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/reset tracker:42 (or target:<id>, or all)
|
||||
+9
@@ -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 %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
List HA areas with entity counts
|
||||
+1
@@ -0,0 +1 @@
|
||||
List entities (optional glob)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show full state for one entity
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show Home Assistant connection status
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
🔍 <b>Entities</b>{% if glob %} matching <code>{{ glob }}</code>{% endif %}
|
||||
{%- if entities %}
|
||||
{%- for e in entities %}
|
||||
<code>{{ e.entity_id }}</code> — <b>{{ e.state }}</b>{% if e.unit_of_measurement %} {{ e.unit_of_measurement }}{% endif %}{% if e.friendly_name and e.friendly_name != e.entity_id %} · <i>{{ e.friendly_name }}</i>{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if total > shown %}
|
||||
<i>Showing {{ shown }} of {{ total }} — refine the glob to narrow further.</i>
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
No entities matched.
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
🏠 <b>Home Assistant commands</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Too many requests. Please wait a moment and try again.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
🏠 <b>Home Assistant bot</b>
|
||||
|
||||
Send /help to see what I can do.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{%- if found %}
|
||||
🏠 <b>{{ friendly_name }}</b>
|
||||
<code>{{ entity_id }}</code>
|
||||
State: <b>{{ state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- if device_class %}
|
||||
Class: <i>{{ device_class }}</i>
|
||||
{%- endif %}
|
||||
{%- if last_changed %}
|
||||
Last changed: <i>{{ last_changed }}</i>
|
||||
{%- endif %}
|
||||
{%- if attributes %}
|
||||
|
||||
<b>Attributes</b>
|
||||
{%- for key, value in attributes.items() %}
|
||||
• {{ key }}: <code>{{ (value if value is string else value | tojson) | string | truncate(120) }}</code>
|
||||
{%- endfor %}
|
||||
{%- if hidden_attr_count and hidden_attr_count > 0 %}
|
||||
<i>… and {{ hidden_attr_count }} more attribute(s) hidden (sensitive or truncated for length)</i>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- elif reason == 'missing_arg' %}
|
||||
Usage: <code>/state <entity_id></code>
|
||||
{%- elif reason == 'not_found' %}
|
||||
Entity <code>{{ entity_id }}</code> not found.
|
||||
{%- else %}
|
||||
Could not load state for <code>{{ entity_id }}</code>: {{ error }}
|
||||
{%- endif %}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
🏠 <b>{{ provider_name }}</b>
|
||||
{%- if ok %}
|
||||
<i>Connected</i> · {{ url }}
|
||||
Entities: <b>{{ entity_count }}</b> · Areas: <b>{{ area_count }}</b>
|
||||
{%- else %}
|
||||
<i>Disconnected</i>
|
||||
<code>{{ message }}</code>
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/entities [glob] e.g. /entities light.*
|
||||
+1
@@ -0,0 +1 @@
|
||||
/state <entity_id> e.g. /state light.kitchen
|
||||
@@ -64,6 +64,24 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
|
||||
# Usage example slots
|
||||
"usage_latest", "usage_search", "usage_random",
|
||||
],
|
||||
"home_assistant": [
|
||||
# Response templates
|
||||
"start", "help", "status", "entities", "state", "areas",
|
||||
"rate_limited", "no_results",
|
||||
# Description slots
|
||||
"desc_help", "desc_status", "desc_entities", "desc_state", "desc_areas",
|
||||
# Usage examples
|
||||
"usage_entities", "usage_state",
|
||||
],
|
||||
"bridge_self": [
|
||||
# Response templates
|
||||
"start", "help", "status", "thresholds", "reset", "health",
|
||||
"rate_limited", "no_results",
|
||||
# Description slots
|
||||
"desc_help", "desc_status", "desc_thresholds", "desc_reset", "desc_health",
|
||||
# Usage examples
|
||||
"usage_reset",
|
||||
],
|
||||
}
|
||||
|
||||
# Backward-compatible aliases
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
Краткая однострочная сводка состояния
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать доступные команды
|
||||
+1
@@ -0,0 +1 @@
|
||||
Сбросить счётчик сбоев (tracker:<id>, target:<id> или all)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user