Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
@@ -29,16 +29,58 @@ jobs:
|
|||||||
- name: Svelte check
|
- name: Svelte check
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run check || echo "::warning::svelte-check reported warnings"
|
npm run check
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
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:
|
build-image:
|
||||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||||
needs: [test-frontend]
|
needs: [test-frontend, test-backend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -10,7 +10,43 @@ env:
|
|||||||
IMAGE_NAME: alexei.dolgolyov/notify-bridge
|
IMAGE_NAME: alexei.dolgolyov/notify-bridge
|
||||||
|
|
||||||
jobs:
|
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:
|
release:
|
||||||
|
needs: [test-backend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- 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.
|
||||||
+90
-15
@@ -1,16 +1,53 @@
|
|||||||
# v0.8.0 (2026-05-12)
|
# v0.8.1 (2026-05-16)
|
||||||
|
|
||||||
|
## ⚠️ Breaking Changes
|
||||||
|
|
||||||
|
- **Telegram webhook secret is now mandatory.** `NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET` must be set when running Telegram bots in webhook mode — the inbound endpoint returns 401 without it. Polling-mode bots are unaffected ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Generic webhooks with `auth_mode="none"` require explicit opt-in.** Existing unauthenticated webhook providers must set `acknowledge_unauthenticated=true` in their config or they will be rejected. Generic webhook ingest is also rate-limited to 60 requests/min per source IP ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Every user now gets a `bridge_self` provider auto-seeded.** It is internal-only and excluded from the "create provider" wizard, but appears in the provider list. Wire it to a Telegram/Email/Matrix target to receive bridge health alerts ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
## User-facing changes
|
## User-facing changes
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **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))
|
- **Home Assistant provider.** New service provider that subscribes to a Home Assistant instance over WebSocket (long-lived connection with auth handshake, exponential-backoff reconnect, area-registry enrichment) and emits 4 event types: `state_changed`, `automation_triggered`, `call_service`, `event_fired`. Trackers support entity-glob, domain-allowlist, and exact-id filters via the new `TagInput` UI control ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
|
||||||
- **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))
|
- **Home Assistant bot commands.** `/status`, `/entities [glob]`, `/state <entity_id>`, `/areas` — query your HA instance from chat. Multi-command WS sessions reuse a single handshake; sensitive attributes (camera access tokens, entity pictures, etc.) are blocklisted and `/state` output is capped at 30 attributes to stay within Telegram message limits ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
|
||||||
- **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))
|
- **Bridge self-monitoring (`bridge_self` provider).** A new internal provider type emits health events from the bridge itself: `bridge_self_poll_failures` (consecutive tracker poll failures), `bridge_self_deferred_backlog` (pending defers above threshold), `bridge_self_target_failures` (consecutive 5xx/network failures per target). Per-user thresholds default to 3 / 100 / 5 and are configurable. Self-loop guard ensures bridge_self failures never count toward target-failure thresholds ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **bridge_self bot commands.** `/status`, `/thresholds`, `/reset`, `/health` let operators inspect bridge health and reset counters from chat. Includes Jinja2 templates for both locales ([8651767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8651767))
|
||||||
|
- **On-watch stats scope selector.** New icon toggle on the "On watch" provider deck switches between page-scoped stats (legacy) and full-corpus stats that aggregate across every event matching the active filters ([dec0839](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/dec0839))
|
||||||
|
|
||||||
### 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))
|
- **Immich periodic summary now honors `periodic_interval_days`.** A configured 3-day interval was firing every day because the dispatch path never consulted the interval or start date. The scheduler now gates on `(today - start_date).days % interval == 0` and logs `interval_not_due` skips so operators can distinguish suppressed-by-interval from other skip causes ([90f958b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90f958b))
|
||||||
|
- **Planka webhook crash fixed.** The handler had a `NameError` on every call when reading the request body — webhook ingest from Planka now works ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **HA quiet-hours dropped events.** `ha_state_changed`, `automation_triggered`, `service_called`, and `event_fired` were missing from the deferrable set and were silently dropped during quiet windows. They now defer like every other event type ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Notifier no longer cancels peer sends on a single failure.** Switched the per-receiver fan-out to `asyncio.gather(return_exceptions=True)`; one bad chat won't cancel sends to the rest ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Quiet-hours gate now respects event-type-disabled.** When a tracker has the event type disabled, that wins over the deferral path — events are dropped, not stored to be drained later ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **NUT first poll seeds silently.** No more spurious `ups_on_battery` notification on the very first poll after adding a NUT provider ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **HA disconnect/reconnect now logged.** Status changes write `ha_status_*` rows to the EventLog so operators can see WS supervisor health in the UI ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Jinja2 template compilation cached** with `lru_cache(maxsize=512)` — repeated renders of the same template no longer reparse the source ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Per-locale render cache in `NotificationDispatcher`** skips re-rendering identical content for receivers sharing a locale ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Tracker list cached per `provider_id`** with a 5s TTL plus explicit invalidation on tracker CRUD — relieves the HA chat-bus rate-query pressure ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Nav-counts collapsed from 16 round-trips to a single `UNION ALL`** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **HA event_log skips empty events** — `assets_added`/`assets_removed` events with empty payloads are no longer persisted ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **DNS-rebinding SSRF protection.** `PinnedResolver` is now wired into the shared aiohttp session — outbound URL validation pins the resolved IP for the lifetime of the request ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Mass-assignment guards** added on Action create/update; cron expressions with sub-minute granularity are rejected ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Backup import hardening.** JSON depth capped at 10, node count at 100k; `_sanitize_config` now extends to all JSON-typed fields on import ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Telegram `_safe_get` walks redirects manually** with SSRF revalidation at every hop ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Bcrypt 72-byte password length cap** with a clear `422` response (was silently truncating before) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Webhook payload body redaction.** Sensitive substring set extended with `oauth`, `client_secret`, `webhook_secret`, `csrf` in both header filter and template-extras filter ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
- **Deep healthcheck at `/api/ready`** — checks DB `SELECT 1`, scheduler running, HA supervisor presence; returns `{ready, checks, errors, version}` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Prometheus metrics at `/api/metrics`** — `deferred_pending`, `event_log_total`, `dispatch_duration`, `poll_failures`, `send_failures` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **New `OPERATIONS.md`** covering deploy, healthchecks, metrics, backup/restore procedures, log handling, common scenarios, and upgrade flow ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,14 +55,50 @@
|
|||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
- 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))
|
- **Shared dispatch pipeline.** Extracted webhook ingest's event-log + deferred-dispatch + quiet-hours code path from `api/webhooks.py` into `services/event_dispatch.py` so HA subscription and webhook ingest now share the same pipeline ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
|
||||||
- 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))
|
- **`ServiceProvider` ABC gains optional `subscribe()`** for push-style providers; `HomeAssistantServiceProvider` uses it via a per-provider supervisor task started in the FastAPI lifespan ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
|
||||||
- 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))
|
- **Provider construction switched from if/elif ladder to factory registry** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
- 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))
|
- **Provider credential resolution unified** across all 5 dispatch sites ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`NotificationDispatcher` hoisted out of the per-tracker loop** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- **New UNIQUE indexes:** `service_provider.webhook_token`, `telegram_bot.webhook_path_id`, partial UNIQUE on `telegram_bot.bot_id`, `telegram_chat(bot_id, chat_id)`, `notification_tracker_target` unique link, partial UNIQUE on `bridge_self` provider per user ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Composite `ix_event_log_user_event_type_created` index** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`save_chat_from_webhook` switched to `ON CONFLICT DO UPDATE`** (was racy under concurrent webhook delivery) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`ondelete=CASCADE` on user-id FKs** (model annotation; app-side cascade delete added for existing data) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`delete_notification_tracker` converted from N+1 to bulk DELETE/UPDATE** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Module-level `asyncio.Lock` replaced with lazy `_get_lock()` pattern** (avoids cross-event-loop binding) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **VACUUM INTO snapshot now `PRAGMA integrity_check`-verified** before being returned ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Batched `receivers/chats/bots` in `load_link_data`** (was per-target N+1) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`flag_modified` on JSON column reassignments** in deferred_dispatch ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **76 `catch (err: any)` sites converted to `errMsg(err)` helper** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`globalProviderFilter` made a pure getter**; reconciliation moved to a one-time `$effect` in `+layout` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Provider-filter binding simplified** — paired `$effect`s and the `_syncingFilter` flag removed; now a one-way derived value ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`entity-cache` got a separate `_refreshing` flag** for background re-fetches so loading spinners don't appear on revalidation ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **`api.ts` 401 handling rewritten:** `AuthRedirectError` class + dedup `_redirecting` flag, `goto()` instead of `window.location.href` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Accessibility:** `aria-expanded` on mobile More menu, `role=switch` + `aria-checked` on Telegram bot toggles ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Provider-specific hardcoding removed:** Immich-only block extracted to descriptor `featureDiscoveryHint` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **5 `svelte-check` null-narrowing errors fixed** in `EventDetailModal` ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **New `TagInput` component** for free-text glob/domain lists; new toggle `ConfigField` type for HA descriptor ([22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2))
|
||||||
|
|
||||||
|
### CI/Build
|
||||||
|
|
||||||
|
- **CI pytest gate added** to `.gitea/workflows/build.yml` and `release.yml` (wheel-built install to dodge editable-install slowness on the hosted runner) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
|
- **New test suites:** `test_bridge_self` (11), `test_gitea_parser` (9), `test_planka_parser` (6), `test_immich_change_detector` (6), `test_backup_roundtrip` (1) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **`command_sync` snapshot+expunge bot before exiting `AsyncSession`** (was raising on detached-instance access) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **HA `asyncio.shield` now drains inner task on cancellation** (was leaking tasks on supervisor restart) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **APScheduler drain job ID resolution upgraded to seconds** (was minute-bucketed; collisions possible) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
- **Webhook payload rollback failures now logged** (were swallowed) ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,9 +106,11 @@
|
|||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| 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 |
|
| [8651767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8651767) | feat: bridge_self bot commands — status, thresholds, reset, health | 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 |
|
| [10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc) | feat: production readiness — security, perf, bug fixes, bridge self-monitoring | 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 |
|
| [22127e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/22127e2) | feat: Home Assistant provider — WebSocket subscription + bot commands | alexei.dolgolyov |
|
||||||
|
| [90f958b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90f958b) | fix(server): honor periodic_interval_days for Immich periodic summary | alexei.dolgolyov |
|
||||||
|
| [dec0839](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/dec0839) | feat: on-watch stats scope selector (page vs all) | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -505,3 +505,52 @@ button:focus-visible, a:focus-visible {
|
|||||||
scroll-behavior: auto !important;
|
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.
|
* API client with JWT auth for the Notify Bridge backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const API_BASE = '/api';
|
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. */
|
/** Normalize a caught error to a user-safe message. */
|
||||||
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||||
if (err instanceof Error && err.message) return err.message;
|
if (err instanceof Error && err.message) return err.message;
|
||||||
@@ -129,11 +162,11 @@ export async function api<T = any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 && token) {
|
if (res.status === 401 && token) {
|
||||||
clearTokens();
|
redirectToLogin();
|
||||||
if (typeof window !== 'undefined') {
|
// Tagged so the caller's catch can distinguish "we already showed
|
||||||
window.location.href = '/login';
|
// the user a redirect" from a real authorization failure they
|
||||||
}
|
// should snackbar.
|
||||||
throw new Error('Unauthorized');
|
throw new AuthRedirectError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
@@ -204,9 +237,8 @@ export async function fetchAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearTokens();
|
redirectToLogin();
|
||||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
throw new AuthRedirectError();
|
||||||
throw new ApiError('Unauthorized', 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -240,34 +240,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</dl>
|
</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">
|
<div class="actions">
|
||||||
{#if displayEvent.provider_id}
|
{#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} />
|
<MdiIcon name="mdiServer" size={14} />
|
||||||
{t('events.openProvider')}
|
{t('events.openProvider')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if displayEvent.telegram_bot_id && isCommand}
|
{#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} />
|
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||||
{t('events.openBot')}
|
{t('events.openBot')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if displayEvent.command_tracker_id && isCommand}
|
{#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} />
|
<MdiIcon name="mdiChat" size={14} />
|
||||||
{t('events.openCommandTracker')}
|
{t('events.openCommandTracker')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if displayEvent.action_id && isAction}
|
{#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} />
|
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||||
{t('events.openAction')}
|
{t('events.openAction')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isCommand && !isAction && displayEvent.tracker_id}
|
{#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} />
|
<MdiIcon name="mdiRadar" size={14} />
|
||||||
{t('events.openTracker')}
|
{t('events.openTracker')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
columns = 2,
|
columns = 2,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
items: GridItem[];
|
items: GridItem[];
|
||||||
value: string | number | null;
|
value: string | number | null;
|
||||||
@@ -24,6 +25,13 @@
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
compact?: 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();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -63,6 +71,7 @@
|
|||||||
value = item.value;
|
value = item.value;
|
||||||
open = false;
|
open = false;
|
||||||
search = '';
|
search = '';
|
||||||
|
onChange?.(item.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
|||||||
@@ -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') },
|
{ 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) ---
|
// --- Auto-refresh interval (dashboard events list) ---
|
||||||
//
|
//
|
||||||
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||||
@@ -175,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
|||||||
...allDescriptors().map(descriptorToGridItem),
|
...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). */
|
/** Provider type selector (no "All" option). */
|
||||||
export const providerTypeItems = (): GridItem[] =>
|
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",
|
"eventsLabel": "events",
|
||||||
"onWatchTitle": "On",
|
"onWatchTitle": "On",
|
||||||
"onWatchEmphasis": "watch",
|
"onWatchEmphasis": "watch",
|
||||||
|
"statsModeTitle": "Provider deck stats scope",
|
||||||
|
"statsModePage": "Page",
|
||||||
|
"statsModeAll": "All",
|
||||||
"noProviders": "No providers yet.",
|
"noProviders": "No providers yet.",
|
||||||
"addProvider": "Add provider",
|
"addProvider": "Add provider",
|
||||||
"addProviderHint": "Connect a service to start tracking",
|
"addProviderHint": "Connect a service to start tracking",
|
||||||
@@ -232,6 +235,20 @@
|
|||||||
"typeNut": "NUT (UPS)",
|
"typeNut": "NUT (UPS)",
|
||||||
"typeGooglePhotos": "Google Photos",
|
"typeGooglePhotos": "Google Photos",
|
||||||
"typeWebhook": "Generic Webhook",
|
"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.",
|
"loadError": "Failed to load providers.",
|
||||||
"externalDomain": "External Domain",
|
"externalDomain": "External Domain",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
@@ -317,6 +334,13 @@
|
|||||||
"selectBoards": "Select boards...",
|
"selectBoards": "Select boards...",
|
||||||
"upsDevices": "UPS Devices",
|
"upsDevices": "UPS Devices",
|
||||||
"selectUpsDevices": "Select 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",
|
"eventTypes": "Event Types",
|
||||||
"notificationTargets": "Notification Targets",
|
"notificationTargets": "Notification Targets",
|
||||||
"scanInterval": "Scan Interval (seconds)",
|
"scanInterval": "Scan Interval (seconds)",
|
||||||
@@ -641,6 +665,14 @@
|
|||||||
"upsOverload": "UPS overloaded",
|
"upsOverload": "UPS overloaded",
|
||||||
"scheduledMessage": "Scheduled message",
|
"scheduledMessage": "Scheduled message",
|
||||||
"webhookReceived": "Webhook received",
|
"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",
|
"trackImages": "Track images",
|
||||||
"trackVideos": "Track videos",
|
"trackVideos": "Track videos",
|
||||||
"favoritesOnly": "Favorites only",
|
"favoritesOnly": "Favorites only",
|
||||||
@@ -1095,6 +1127,18 @@
|
|||||||
"scopeInherit": "Inherit: derive from notification routing",
|
"scopeInherit": "Inherit: derive from notification routing",
|
||||||
"noCollections": "No albums available."
|
"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": {
|
"snackbar": {
|
||||||
"showDetails": "Show details",
|
"showDetails": "Show details",
|
||||||
"hideDetails": "Hide details"
|
"hideDetails": "Hide details"
|
||||||
@@ -1177,6 +1221,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"auto": "Auto",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -1318,6 +1363,8 @@
|
|||||||
"refresh30s": "Refresh every 30 seconds",
|
"refresh30s": "Refresh every 30 seconds",
|
||||||
"refresh60s": "Refresh every minute",
|
"refresh60s": "Refresh every minute",
|
||||||
"refresh5m": "Refresh every 5 minutes",
|
"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",
|
"newestFirst": "Most recent events on top",
|
||||||
"oldestFirst": "Oldest events on top",
|
"oldestFirst": "Oldest events on top",
|
||||||
"chatActionNone": "No indicator shown",
|
"chatActionNone": "No indicator shown",
|
||||||
@@ -1340,7 +1387,9 @@
|
|||||||
"providerScheduler": "Time-based scheduled messages",
|
"providerScheduler": "Time-based scheduled messages",
|
||||||
"providerNut": "Network UPS monitoring",
|
"providerNut": "Network UPS monitoring",
|
||||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
"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": {
|
"webhookLogs": {
|
||||||
"title": "Recent Payloads",
|
"title": "Recent Payloads",
|
||||||
|
|||||||
@@ -157,6 +157,9 @@
|
|||||||
"eventsLabel": "событий",
|
"eventsLabel": "событий",
|
||||||
"onWatchTitle": "На",
|
"onWatchTitle": "На",
|
||||||
"onWatchEmphasis": "слежении",
|
"onWatchEmphasis": "слежении",
|
||||||
|
"statsModeTitle": "Область статистики провайдеров",
|
||||||
|
"statsModePage": "Страница",
|
||||||
|
"statsModeAll": "Все",
|
||||||
"noProviders": "Пока нет провайдеров.",
|
"noProviders": "Пока нет провайдеров.",
|
||||||
"addProvider": "Добавить",
|
"addProvider": "Добавить",
|
||||||
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
||||||
@@ -232,6 +235,20 @@
|
|||||||
"typeNut": "NUT (ИБП)",
|
"typeNut": "NUT (ИБП)",
|
||||||
"typeGooglePhotos": "Google Фото",
|
"typeGooglePhotos": "Google Фото",
|
||||||
"typeWebhook": "Универсальный вебхук",
|
"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": "Не удалось загрузить провайдеры.",
|
"loadError": "Не удалось загрузить провайдеры.",
|
||||||
"externalDomain": "Внешний домен",
|
"externalDomain": "Внешний домен",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
@@ -317,6 +334,13 @@
|
|||||||
"selectBoards": "Выберите доски...",
|
"selectBoards": "Выберите доски...",
|
||||||
"upsDevices": "ИБП устройства",
|
"upsDevices": "ИБП устройства",
|
||||||
"selectUpsDevices": "Выберите ИБП...",
|
"selectUpsDevices": "Выберите ИБП...",
|
||||||
|
"entities": "Сущности",
|
||||||
|
"selectEntities": "Выберите сущности...",
|
||||||
|
"entities_count": "сущность(ей)",
|
||||||
|
"haEntityGlob": "Фильтр по entity (glob)",
|
||||||
|
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||||
|
"haDomainAllowlist": "Разрешённые домены",
|
||||||
|
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||||
"eventTypes": "Типы событий",
|
"eventTypes": "Типы событий",
|
||||||
"notificationTargets": "Получатели уведомлений",
|
"notificationTargets": "Получатели уведомлений",
|
||||||
"scanInterval": "Интервал проверки (секунды)",
|
"scanInterval": "Интервал проверки (секунды)",
|
||||||
@@ -641,6 +665,14 @@
|
|||||||
"upsOverload": "Перегрузка ИБП",
|
"upsOverload": "Перегрузка ИБП",
|
||||||
"scheduledMessage": "Запланированное сообщение",
|
"scheduledMessage": "Запланированное сообщение",
|
||||||
"webhookReceived": "Вебхук получен",
|
"webhookReceived": "Вебхук получен",
|
||||||
|
"haStateChanged": "Состояние сущности изменилось",
|
||||||
|
"haAutomationTriggered": "Сработала автоматизация",
|
||||||
|
"haServiceCalled": "Вызвана служба",
|
||||||
|
"haEventFired": "Прочее событие HA (catch-all)",
|
||||||
|
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||||
|
"bridgeSelfPollFailures": "Сбои опроса трекера",
|
||||||
|
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
|
||||||
|
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
|
||||||
"trackImages": "Фото",
|
"trackImages": "Фото",
|
||||||
"trackVideos": "Видео",
|
"trackVideos": "Видео",
|
||||||
"favoritesOnly": "Только избранные",
|
"favoritesOnly": "Только избранные",
|
||||||
@@ -1095,6 +1127,18 @@
|
|||||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||||
"noCollections": "Нет доступных альбомов."
|
"noCollections": "Нет доступных альбомов."
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"bridgeSelf": {
|
||||||
|
"status": "Состояние моста",
|
||||||
|
"statusDesc": "Показать счётчики состояния моста",
|
||||||
|
"thresholds": "Пороги моста",
|
||||||
|
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||||
|
"reset": "Сбросить счётчик",
|
||||||
|
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||||
|
"health": "Здоровье моста",
|
||||||
|
"healthDesc": "Краткая однострочная сводка состояния"
|
||||||
|
}
|
||||||
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
"showDetails": "Показать детали",
|
"showDetails": "Показать детали",
|
||||||
"hideDetails": "Скрыть детали"
|
"hideDetails": "Скрыть детали"
|
||||||
@@ -1177,6 +1221,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
"auto": "Авто",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
@@ -1318,6 +1363,8 @@
|
|||||||
"refresh30s": "Обновлять каждые 30 секунд",
|
"refresh30s": "Обновлять каждые 30 секунд",
|
||||||
"refresh60s": "Обновлять каждую минуту",
|
"refresh60s": "Обновлять каждую минуту",
|
||||||
"refresh5m": "Обновлять каждые 5 минут",
|
"refresh5m": "Обновлять каждые 5 минут",
|
||||||
|
"statsModePage": "Учитывать только события на текущей странице",
|
||||||
|
"statsModeAll": "Учитывать все события под текущими фильтрами",
|
||||||
"newestFirst": "Сначала новые события",
|
"newestFirst": "Сначала новые события",
|
||||||
"oldestFirst": "Сначала старые события",
|
"oldestFirst": "Сначала старые события",
|
||||||
"chatActionNone": "Индикатор не показывается",
|
"chatActionNone": "Индикатор не показывается",
|
||||||
@@ -1340,7 +1387,9 @@
|
|||||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||||
"providerNut": "Мониторинг ИБП через NUT",
|
"providerNut": "Мониторинг ИБП через NUT",
|
||||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||||
"providerWebhook": "Приём событий через HTTP POST"
|
"providerWebhook": "Приём событий через HTTP POST",
|
||||||
|
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
|
||||||
|
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
|
||||||
},
|
},
|
||||||
"webhookLogs": {
|
"webhookLogs": {
|
||||||
"title": "Последние запросы",
|
"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(' · ');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -113,6 +113,17 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
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 }) {
|
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
||||||
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
||||||
if (newIds.length === 0) return { proceed: true };
|
if (newIds.length === 0) return { proceed: true };
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
|
|||||||
import { nutDescriptor } from './nut';
|
import { nutDescriptor } from './nut';
|
||||||
import { googlePhotosDescriptor } from './google-photos';
|
import { googlePhotosDescriptor } from './google-photos';
|
||||||
import { webhookDescriptor } from './webhook';
|
import { webhookDescriptor } from './webhook';
|
||||||
|
import { homeAssistantDescriptor } from './home-assistant';
|
||||||
|
import { bridgeSelfDescriptor } from './bridge-self';
|
||||||
|
|
||||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||||
['immich', immichDescriptor],
|
['immich', immichDescriptor],
|
||||||
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
|||||||
['nut', nutDescriptor],
|
['nut', nutDescriptor],
|
||||||
['google_photos', googlePhotosDescriptor],
|
['google_photos', googlePhotosDescriptor],
|
||||||
['webhook', webhookDescriptor],
|
['webhook', webhookDescriptor],
|
||||||
|
['home_assistant', homeAssistantDescriptor],
|
||||||
|
['bridge_self', bridgeSelfDescriptor],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface ConfigField {
|
|||||||
configKey?: string;
|
configKey?: string;
|
||||||
/** i18n key for the field label. */
|
/** i18n key for the field label. */
|
||||||
label: string;
|
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. */
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
gridItems?: string;
|
gridItems?: string;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
@@ -123,17 +123,30 @@ export interface CollectionMeta {
|
|||||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares a filter that picks user identities from the provider's known
|
* Declares a filter rendered on the tracker form. Two input modes:
|
||||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
*
|
||||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
* * ``picker`` (default) — populated from the provider's ``/users``
|
||||||
* `tracker.filters[key]`.
|
* 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 {
|
export interface UserFilterMeta {
|
||||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
/** Form field key — used internally for binding. */
|
||||||
key: string;
|
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;
|
label: string;
|
||||||
/** i18n key for the picker placeholder. */
|
/** i18n key for the placeholder (picker dropdown or chip input). */
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
/** MDI icon shown on chips and dropdown rows. */
|
/** MDI icon shown on chips and dropdown rows. */
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -183,6 +196,22 @@ export interface ProviderDescriptor {
|
|||||||
/** Whether this provider stores incoming payload history for debugging. */
|
/** Whether this provider stores incoming payload history for debugging. */
|
||||||
payloadHistory?: boolean;
|
payloadHistory?: 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 ──
|
// ── Provider-specific hooks ──
|
||||||
/**
|
/**
|
||||||
* Called after collection selection changes (before save).
|
* 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 }> {
|
export interface EntityCache<T extends { id: number }> {
|
||||||
/** Reactive list of cached entities. */
|
/** Reactive list of cached entities. */
|
||||||
readonly items: T[];
|
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;
|
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. */
|
/** Timestamp of last successful fetch. */
|
||||||
readonly fetchedAt: number;
|
readonly fetchedAt: number;
|
||||||
/** Fetch entities — returns cached data if fresh, else hits network. */
|
/** Fetch entities — returns cached data if fresh, else hits network. */
|
||||||
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
): EntityCache<T> {
|
): EntityCache<T> {
|
||||||
let _items = $state<T[]>([]);
|
let _items = $state<T[]>([]);
|
||||||
let _loading = $state(false);
|
let _loading = $state(false);
|
||||||
|
let _refreshing = $state(false);
|
||||||
let _fetchedAt = $state(0);
|
let _fetchedAt = $state(0);
|
||||||
|
|
||||||
function isFresh(): boolean {
|
function isFresh(): boolean {
|
||||||
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
const existing = inflightRequests.get(endpoint);
|
const existing = inflightRequests.get(endpoint);
|
||||||
if (existing) return existing;
|
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;
|
const isFirstLoad = _fetchedAt === 0;
|
||||||
if (isFirstLoad) _loading = true;
|
if (isFirstLoad) _loading = true;
|
||||||
|
else _refreshing = true;
|
||||||
|
|
||||||
const request = api<T[]>(endpoint)
|
const request = api<T[]>(endpoint)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
_refreshing = false;
|
||||||
inflightRequests.delete(endpoint);
|
inflightRequests.delete(endpoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
return {
|
return {
|
||||||
get items() { return _items; },
|
get items() { return _items; },
|
||||||
get loading() { return _loading; },
|
get loading() { return _loading; },
|
||||||
|
get refreshing() { return _refreshing; },
|
||||||
get fetchedAt() { return _fetchedAt; },
|
get fetchedAt() { return _fetchedAt; },
|
||||||
fetch,
|
fetch,
|
||||||
invalidate,
|
invalidate,
|
||||||
|
|||||||
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
|
|||||||
loadFromStorage();
|
loadFromStorage();
|
||||||
|
|
||||||
export const globalProviderFilter = {
|
export const globalProviderFilter = {
|
||||||
get id() {
|
/**
|
||||||
// If providers are loaded and the stored ID doesn't match any, auto-clear
|
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
|
||||||
if (_providerId != null && providersCache.items.length > 0 &&
|
* reconciliation against `providersCache` is the responsibility of a
|
||||||
!providersCache.items.some(p => p.id === _providerId)) {
|
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
|
||||||
globalProviderFilter.clear();
|
* because writing during read inside a `$state`-derived getter triggers
|
||||||
return null;
|
* Svelte 5's `state_unsafe_mutation` warning.
|
||||||
}
|
*/
|
||||||
return _providerId;
|
get id() { return _providerId; },
|
||||||
},
|
|
||||||
get initialized() { return _initialized; },
|
get initialized() { return _initialized; },
|
||||||
|
|
||||||
set(id: number | null) {
|
set(id: number | null) {
|
||||||
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
|
|||||||
this.set(null);
|
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). */
|
/** The currently selected provider object (reactive). */
|
||||||
get provider() {
|
get provider() {
|
||||||
const id = this.id; // triggers stale-ID auto-clear
|
const id = _providerId;
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
return providersCache.items.find(p => p.id === id) ?? null;
|
return providersCache.items.find(p => p.id === id) ?? null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ export interface DashboardStatus {
|
|||||||
total_events: number;
|
total_events: number;
|
||||||
recent_events: EventLog[];
|
recent_events: EventLog[];
|
||||||
command_trackers?: number;
|
command_trackers?: number;
|
||||||
|
/** Provider name → total event count across ALL events matching the
|
||||||
|
* current filters (ignores pagination). Powers the "On watch" deck
|
||||||
|
* when the user opts out of page-scoped stats. */
|
||||||
|
provider_event_counts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade, slide } from 'svelte/transition';
|
import { fade, slide } from 'svelte/transition';
|
||||||
import { cubicOut } from 'svelte/easing';
|
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 { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
@@ -46,30 +46,40 @@
|
|||||||
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
||||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||||
]);
|
]);
|
||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
// One-way: the store is the source of truth, the filter widget displays it.
|
||||||
let _syncingFilter = false;
|
// 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.
|
// 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
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||||
// hard reload — the most visible "jump" the user reported.
|
// hard reload — the most visible "jump" the user reported.
|
||||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
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(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
// Track `fetchedAt` so we re-run after the cache loads.
|
||||||
if (_syncingFilter) return;
|
void providersCache.fetchedAt;
|
||||||
globalProviderFilter.set(v === 0 ? null : v);
|
void providersCache.items.length;
|
||||||
|
globalProviderFilter.reconcileWithCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync store → filter value (handles auto-clear of stale IDs)
|
function setProviderFilter(v: string | number) {
|
||||||
$effect(() => {
|
const num = typeof v === 'number' ? v : Number(v);
|
||||||
const storeId = globalProviderFilter.id;
|
globalProviderFilter.set(num === 0 ? null : num);
|
||||||
if (storeId === null && providerFilterValue !== 0) {
|
}
|
||||||
_syncingFilter = true;
|
|
||||||
providerFilterValue = 0;
|
// Collapsed-rail filter cycles through providers via the same setter so the
|
||||||
_syncingFilter = false;
|
// 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 showPasswordForm = $state(false);
|
||||||
let redirecting = $state(false);
|
let redirecting = $state(false);
|
||||||
@@ -91,7 +101,7 @@
|
|||||||
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
|
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
|
// Read persisted UI state synchronously so first paint already matches the
|
||||||
@@ -446,18 +456,14 @@
|
|||||||
{#if showProviderFilter}
|
{#if showProviderFilter}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={cycleProviderFilter}
|
||||||
const ids = [0, ...allProviders.map(p => p.id)];
|
|
||||||
const idx = ids.indexOf(providerFilterValue);
|
|
||||||
providerFilterValue = ids[(idx + 1) % ids.length];
|
|
||||||
}}
|
|
||||||
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
|
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')}
|
title={globalProviderFilter.provider?.name || t('common.allProviders')}
|
||||||
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||||
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -595,6 +601,7 @@
|
|||||||
<NavIcon name="mdiMagnify" size={20} />
|
<NavIcon name="mdiMagnify" size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
|
<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"
|
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)'};">
|
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||||
<NavIcon name="mdiDotsHorizontal" size={20} />
|
<NavIcon name="mdiDotsHorizontal" size={20} />
|
||||||
@@ -609,7 +616,7 @@
|
|||||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||||
{#if allProviders.length >= 1}
|
{#if allProviders.length >= 1}
|
||||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.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 { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
|
|
||||||
@@ -76,6 +76,17 @@
|
|||||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
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.
|
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
|
||||||
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
||||||
// this whitelist in sync with that helper so a stale localStorage
|
// this whitelist in sync with that helper so a stale localStorage
|
||||||
@@ -95,6 +106,7 @@
|
|||||||
let eventsLoading = $state(false);
|
let eventsLoading = $state(false);
|
||||||
let confirmClearEvents = $state(false);
|
let confirmClearEvents = $state(false);
|
||||||
let refreshSeconds = $state(loadRefreshSeconds());
|
let refreshSeconds = $state(loadRefreshSeconds());
|
||||||
|
let providerStatsMode = $state(loadProviderStatsMode());
|
||||||
let selectedEvent = $state<EventLog | null>(null);
|
let selectedEvent = $state<EventLog | null>(null);
|
||||||
// Stagger entry animation should play once on initial load only —
|
// Stagger entry animation should play once on initial load only —
|
||||||
// without this, every pagination/filter change re-runs the cascade
|
// 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));
|
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() {
|
async function clearEvents() {
|
||||||
try {
|
try {
|
||||||
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
||||||
@@ -197,6 +217,7 @@
|
|||||||
targets: next.targets,
|
targets: next.targets,
|
||||||
total_events: next.total_events,
|
total_events: next.total_events,
|
||||||
command_trackers: next.command_trackers,
|
command_trackers: next.command_trackers,
|
||||||
|
provider_event_counts: next.provider_event_counts,
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -298,9 +319,21 @@
|
|||||||
: displayProviders);
|
: displayProviders);
|
||||||
|
|
||||||
// === Provider deck — derive activity counts from recent events ===
|
// === 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 providerEventCounts = $derived.by(() => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
if (!status) return counts;
|
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) {
|
for (const ev of status.recent_events) {
|
||||||
const k = ev.provider_name || '';
|
const k = ev.provider_name || '';
|
||||||
if (!k) continue;
|
if (!k) continue;
|
||||||
@@ -812,11 +845,18 @@
|
|||||||
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
|
<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>
|
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick={() => toggleSection('on_watch')}
|
<div class="panel-head-actions">
|
||||||
class="ghost-icon-btn"
|
<div class="w-32" title={t('dashboard.statsModeTitle')}>
|
||||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
<IconGridSelect items={providerStatsModeItems()}
|
||||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
bind:value={providerStatsMode}
|
||||||
</button>
|
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>
|
</header>
|
||||||
|
|
||||||
{#if sectionExpanded.on_watch}
|
{#if sectionExpanded.on_watch}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -99,8 +99,8 @@
|
|||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('actions.loadError');
|
loadError = errMsg(err, t('actions.loadError'));
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
}
|
}
|
||||||
showForm = false; editing = null; actionsCache.invalidate(); await load();
|
showForm = false; editing = null; actionsCache.invalidate(); await load();
|
||||||
snackSuccess(t('actions.saved'));
|
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;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
await api(`/actions/${id}`, { method: 'DELETE' });
|
await api(`/actions/${id}`, { method: 'DELETE' });
|
||||||
actionsCache.invalidate(); await load();
|
actionsCache.invalidate(); await load();
|
||||||
snackSuccess(t('actions.deleted'));
|
snackSuccess(t('actions.deleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeAction(id: number, dryRun = false) {
|
async function executeAction(id: number, dryRun = false) {
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
|
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
|
||||||
snackSuccess(msg);
|
snackSuccess(msg);
|
||||||
actionsCache.invalidate(); await load();
|
actionsCache.invalidate(); await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
executing = { ...executing, [id]: false };
|
executing = { ...executing, [id]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache } from '$lib/stores/caches.svelte';
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
|
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
people = Array.isArray(p) ? p : [];
|
people = Array.isArray(p) ? p : [];
|
||||||
albums = Array.isArray(a) ? a : [];
|
albums = Array.isArray(a) ? a : [];
|
||||||
} catch {
|
} catch {
|
||||||
// People/album endpoints may not exist yet — degrade gracefully
|
// People/album endpoints may not exist yet — degrade gracefully
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
resetNewRule();
|
resetNewRule();
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleSaved'));
|
snackSuccess(t('actions.ruleSaved'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
});
|
});
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleSaved'));
|
snackSuccess(t('actions.ruleSaved'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRule(ruleId: number) {
|
async function deleteRule(ruleId: number) {
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
|
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleDeleted'));
|
snackSuccess(t('actions.ruleDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRule(rule: ActionRule) {
|
async function toggleRule(rule: ActionRule) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
emailBotsCache.fetch(true),
|
emailBotsCache.fetch(true),
|
||||||
matrixBotsCache.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(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
snackSuccess(t('snack.emailBotCreated'));
|
snackSuccess(t('snack.emailBotCreated'));
|
||||||
}
|
}
|
||||||
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
|
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { emailSubmitting = false; }
|
finally { emailSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +99,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDeleteEmail = null; }
|
finally { confirmDeleteEmail = null; }
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||||
else snackError(res.error || t('emailBot.operationFailed'));
|
else snackError(res.error || t('emailBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
emailTesting = { ...emailTesting, [botId]: false };
|
emailTesting = { ...emailTesting, [botId]: false };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
snackSuccess(t('snack.matrixBotCreated'));
|
snackSuccess(t('snack.matrixBotCreated'));
|
||||||
}
|
}
|
||||||
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
|
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { matrixSubmitting = false; }
|
finally { matrixSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +95,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDeleteMatrix = null; }
|
finally { confirmDeleteMatrix = null; }
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||||
else snackError(res.error || t('matrixBot.operationFailed'));
|
else snackError(res.error || t('matrixBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
matrixTesting = { ...matrixTesting, [botId]: false };
|
matrixTesting = { ...matrixTesting, [botId]: false };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide, fade } from 'svelte/transition';
|
import { slide, fade } from 'svelte/transition';
|
||||||
import { flip } from 'svelte/animate';
|
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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
snackSuccess(t('snack.botRegistered'));
|
snackSuccess(t('snack.botRegistered'));
|
||||||
}
|
}
|
||||||
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
|
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +115,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
try {
|
try {
|
||||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +156,15 @@
|
|||||||
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
||||||
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
|
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
|
||||||
snackSuccess(t('telegramBot.chatDeleted'));
|
snackSuccess(t('telegramBot.chatDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANG_ITEMS = [
|
// `desc` is the only locale-sensitive field — language *names* are intentionally
|
||||||
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
|
// 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: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
|
||||||
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
|
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
|
||||||
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
|
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
|
||||||
@@ -177,7 +181,7 @@
|
|||||||
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
|
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
|
||||||
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
|
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
|
||||||
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
|
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
|
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
|
||||||
try {
|
try {
|
||||||
@@ -189,7 +193,7 @@
|
|||||||
c.id === chat.id ? { ...c, language_override: lang } : c
|
c.id === chat.id ? { ...c, language_override: lang } : c
|
||||||
);
|
);
|
||||||
snackSuccess(t('telegramBot.languageUpdated'));
|
snackSuccess(t('telegramBot.languageUpdated'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
||||||
@@ -202,7 +206,7 @@
|
|||||||
chats[botId] = (chats[botId] || []).map(c =>
|
chats[botId] = (chats[botId] || []).map(c =>
|
||||||
c.id === chat.id ? { ...c, commands_enabled: newVal } : 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) {
|
async function loadListenerStatus(botId: number) {
|
||||||
@@ -232,7 +236,7 @@
|
|||||||
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
|
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) {
|
async function syncCommands(botId: number) {
|
||||||
@@ -241,7 +245,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +258,7 @@
|
|||||||
await loadWebhookStatus(botId);
|
await loadWebhookStatus(botId);
|
||||||
}
|
}
|
||||||
snackSuccess(t('snack.botUpdated'));
|
snackSuccess(t('snack.botUpdated'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +278,7 @@
|
|||||||
} else {
|
} else {
|
||||||
snackError(res.error || t('telegramBot.webhookFailed'));
|
snackError(res.error || t('telegramBot.webhookFailed'));
|
||||||
}
|
}
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +288,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
||||||
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +319,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
chatTesting = { ...chatTesting, [key]: false };
|
chatTesting = { ...chatTesting, [key]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +468,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||||
<button
|
<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)'};"
|
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')}
|
title={t('telegramBot.commandsToggle')}
|
||||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
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>
|
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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)'};"
|
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')}
|
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||||
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
commandTemplateConfigsCache.fetch(),
|
commandTemplateConfigsCache.fetch(),
|
||||||
capabilitiesCache.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(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
snackSuccess(t('snack.commandConfigSaved'));
|
snackSuccess(t('snack.commandConfigSaved'));
|
||||||
}
|
}
|
||||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +227,10 @@
|
|||||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.commandConfigDeleted'));
|
snackSuccess(t('snack.commandConfigDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
@@ -213,8 +213,8 @@
|
|||||||
allCmdTplConfigs = cfgs;
|
allCmdTplConfigs = cfgs;
|
||||||
allCapabilities = caps;
|
allCapabilities = caps;
|
||||||
varsRef = vars;
|
varsRef = vars;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message || t('common.loadError');
|
error = errMsg(err, t('common.loadError'));
|
||||||
snackError(error);
|
snackError(error);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
@@ -354,9 +354,10 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.cmdTemplateSaved'));
|
snackSuccess(t('snack.cmdTemplateSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,8 +408,8 @@
|
|||||||
refreshAllPreviews();
|
refreshAllPreviews();
|
||||||
}
|
}
|
||||||
snackSuccess(t('templateConfig.resetApplied'));
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,11 +445,12 @@
|
|||||||
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
}
|
}
|
||||||
@@ -587,7 +589,7 @@
|
|||||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||||
{:else}
|
{: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}
|
||||||
{/if}
|
{/if}
|
||||||
</CollapsibleSlot>
|
</CollapsibleSlot>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
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 { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
providersCache.fetch(), commandConfigsCache.fetch(),
|
providersCache.fetch(), commandConfigsCache.fetch(),
|
||||||
telegramBotsCache.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(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
snackSuccess(t('snack.commandTrackerCreated'));
|
snackSuccess(t('snack.commandTrackerCreated'));
|
||||||
}
|
}
|
||||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.commandTrackerDeleted'));
|
snackSuccess(t('snack.commandTrackerDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||||
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
toggling = { ...toggling, [trk.id]: false };
|
toggling = { ...toggling, [trk.id]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
snackSuccess(t('snack.listenerAdded'));
|
snackSuccess(t('snack.listenerAdded'));
|
||||||
await loadListeners(trkId);
|
await loadListeners(trkId);
|
||||||
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
addingListener = { ...addingListener, [trkId]: false };
|
addingListener = { ...addingListener, [trkId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
||||||
snackSuccess(t('snack.listenerRemoved'));
|
snackSuccess(t('snack.listenerRemoved'));
|
||||||
await loadListeners(trkId);
|
await loadListeners(trkId);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-listener album scope editing
|
// Per-listener album scope editing
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
snackSuccess(t('snack.listenerScopeSaved'));
|
snackSuccess(t('snack.listenerScopeSaved'));
|
||||||
await loadListeners(scopeEditor.trkId);
|
await loadListeners(scopeEditor.trkId);
|
||||||
scopeEditor = null;
|
scopeEditor = null;
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerName(id: number): string {
|
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]} />
|
<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)}
|
<button onclick={() => toggleListeners(trk.id)}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
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>
|
</button>
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
@@ -473,7 +473,7 @@
|
|||||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||||
{t('backup.selectAll')}
|
{t('backup.selectAll')}
|
||||||
</button>
|
</button>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">В·</span>
|
||||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||||
{t('backup.deselectAll')}
|
{t('backup.deselectAll')}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { login } from '$lib/auth.svelte';
|
import { login } from '$lib/auth.svelte';
|
||||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||||
if (res.needs_setup) goto('/setup');
|
if (res.needs_setup) goto('/setup');
|
||||||
} catch {
|
} 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.
|
// doesn't blame the login form for a network/backend problem.
|
||||||
backendDown = true;
|
backendDown = true;
|
||||||
}
|
}
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message || t('auth.loginFailed');
|
error = errMsg(err, t('auth.loginFailed'));
|
||||||
}
|
}
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
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 { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -166,8 +166,8 @@
|
|||||||
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('common.loadFailed');
|
loadError = errMsg(err, t('common.loadFailed'));
|
||||||
snackError(loadError);
|
snackError(loadError);
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
snackSuccess(t('snack.trackerCreated'));
|
snackSuccess(t('snack.trackerCreated'));
|
||||||
}
|
}
|
||||||
showForm = false; editing = null; linkWarning = null; await load();
|
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() {
|
async function autoCreateLinks() {
|
||||||
@@ -301,8 +301,8 @@
|
|||||||
try {
|
try {
|
||||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
||||||
created++;
|
created++;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
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 api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
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; }
|
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.trackerDeleted'));
|
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;
|
confirmDelete = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
newLinkTemplateConfigId[trackerId] = 0;
|
newLinkTemplateConfigId[trackerId] = 0;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetLinked'));
|
snackSuccess(t('snack.targetLinked'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
addingTarget = { ...addingTarget, [trackerId]: false };
|
addingTarget = { ...addingTarget, [trackerId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetUnlinked'));
|
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) {
|
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
||||||
@@ -470,7 +470,7 @@
|
|||||||
body: JSON.stringify({ [field]: value }),
|
body: JSON.stringify({ [field]: value }),
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||||
@@ -492,8 +492,8 @@
|
|||||||
} else {
|
} else {
|
||||||
snackSuccess(t('snack.targetTestSent'));
|
snackSuccess(t('snack.targetTestSent'));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
ttTesting = { ...ttTesting, [key]: '' };
|
ttTesting = { ...ttTesting, [key]: '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n';
|
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 { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
let replacing = $state<Record<string, boolean>>({});
|
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
|
* 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).
|
* 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.
|
* 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'));
|
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||||
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||||
if (onupdate) onupdate(remaining);
|
if (onupdate) onupdate(remaining);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
replacing = { ...replacing, [album.id]: false };
|
replacing = { ...replacing, [album.id]: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,9 +59,36 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let descriptor = $derived(getDescriptor(providerType));
|
let descriptor = $derived(getDescriptor(providerType));
|
||||||
let isScheduler = $derived(providerType === 'scheduler');
|
|
||||||
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
|
||||||
let colMeta = $derived(descriptor?.collectionMeta);
|
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
|
// Custom variable management for scheduler
|
||||||
function addVariable() {
|
function addVariable() {
|
||||||
@@ -123,14 +151,24 @@
|
|||||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||||
{#each descriptor.userFilters as uf (uf.key)}
|
{#each descriptor.userFilters as uf (uf.key)}
|
||||||
|
{@const filterKey = uf.filterKey ?? uf.key}
|
||||||
<div>
|
<div>
|
||||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||||
<MultiEntitySelect
|
{#if uf.inputMode === 'tags'}
|
||||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
<TagInput
|
||||||
values={form.filters[uf.key] || []}
|
values={form.filters[filterKey] || []}
|
||||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||||
placeholder={t(uf.placeholder)}
|
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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -220,29 +258,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||||
live on the tracking config, not on the tracker itself. Surface this
|
live on the tracking config, not on the tracker itself. The hint
|
||||||
here so users don't have to stumble onto the feature by reading docs. -->
|
content (message + CTAs) is declared on the provider descriptor so
|
||||||
{#if providerType === 'immich'}
|
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">
|
<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>
|
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||||
<div class="flex-1 text-xs">
|
<div class="flex-1 text-xs">
|
||||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
{#if hint.ctas && hint.ctas.length > 0}
|
||||||
<a href={form.default_tracking_config_id
|
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
{#each hint.ctas as cta}
|
||||||
: '/tracking-configs'}
|
<a href={resolveHintHref(cta.href)}
|
||||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||||
<MdiIcon name="mdiArrowRight" size={12} />
|
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
|
||||||
{t('notificationTracker.openTrackingConfig')}
|
{t(cta.labelKey)}
|
||||||
</a>
|
</a>
|
||||||
<a href={form.default_template_config_id
|
{/each}
|
||||||
? `/template-configs?edit=${form.default_template_config_id}`
|
</div>
|
||||||
: '/template-configs'}
|
{/if}
|
||||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
|
||||||
<MdiIcon name="mdiArrowRight" size={12} />
|
|
||||||
{t('notificationTracker.openTemplateConfig')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
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 { t } from '$lib/i18n';
|
||||||
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -169,8 +169,8 @@
|
|||||||
try {
|
try {
|
||||||
await providersCache.fetch(true);
|
await providersCache.fetch(true);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('providers.loadError');
|
loadError = errMsg(err, t('providers.loadError'));
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
// Ping all providers in background (use unfiltered list)
|
// Ping all providers in background (use unfiltered list)
|
||||||
for (const p of allProviders) {
|
for (const p of allProviders) {
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
}
|
}
|
||||||
showForm = false; editing = null; providersCache.invalidate(); await load();
|
showForm = false; editing = null; providersCache.invalidate(); await load();
|
||||||
snackSuccess(t('snack.providerSaved'));
|
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;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +248,10 @@
|
|||||||
const id = confirmDelete.id;
|
const id = confirmDelete.id;
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
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);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -321,6 +321,11 @@
|
|||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||||
min={field.min} max={field.max}
|
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)]" />
|
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}
|
{:else}
|
||||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||||
required={field.required === true || (field.required === 'create-only' && !editing)}
|
required={field.required === true || (field.required === 'create-only' && !editing)}
|
||||||
|
|||||||
@@ -107,6 +107,11 @@
|
|||||||
{:else if field.type === 'number'}
|
{:else if field.type === 'number'}
|
||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
<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)]" />
|
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}
|
{:else}
|
||||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||||
required={field.required === true || field.required === 'create-only'}
|
required={field.required === true || field.required === 'create-only'}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, fetchAuth } from '$lib/api';
|
import { api, fetchAuth , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
@@ -97,9 +97,10 @@
|
|||||||
scheduledSettings = settings;
|
scheduledSettings = settings;
|
||||||
backupFiles = files;
|
backupFiles = files;
|
||||||
pending = p;
|
pending = p;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||||
snackSuccess(t('backup.pendingCancelled'));
|
snackSuccess(t('backup.pendingCancelled'));
|
||||||
pending = null;
|
pending = null;
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAndRestart(): Promise<void> {
|
async function applyAndRestart(): Promise<void> {
|
||||||
@@ -131,9 +132,9 @@
|
|||||||
if (attempts < 120) setTimeout(poll, 1000);
|
if (attempts < 120) setTimeout(poll, 1000);
|
||||||
};
|
};
|
||||||
setTimeout(poll, 1500);
|
setTimeout(poll, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
restartingOverlay = false;
|
restartingOverlay = false;
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +145,8 @@
|
|||||||
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||||
snackSuccess(t('backup.manualCreated'));
|
snackSuccess(t('backup.manualCreated'));
|
||||||
await refreshFiles();
|
await refreshFiles();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
creatingBackup = false;
|
creatingBackup = false;
|
||||||
}
|
}
|
||||||
@@ -178,8 +179,8 @@
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
snackSuccess(t('backup.exportSuccess'));
|
snackSuccess(t('backup.exportSuccess'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
exporting = false;
|
exporting = false;
|
||||||
}
|
}
|
||||||
@@ -202,8 +203,8 @@
|
|||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||||
validationResult = await res.json();
|
validationResult = await res.json();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
validating = false;
|
validating = false;
|
||||||
}
|
}
|
||||||
@@ -230,8 +231,8 @@
|
|||||||
snackSuccess(t('backup.restorePrepared'));
|
snackSuccess(t('backup.restorePrepared'));
|
||||||
postRestoreModalOpen = true;
|
postRestoreModalOpen = true;
|
||||||
importFile = null;
|
importFile = null;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
importing = false;
|
importing = false;
|
||||||
}
|
}
|
||||||
@@ -246,8 +247,8 @@
|
|||||||
body: JSON.stringify(scheduledSettings),
|
body: JSON.stringify(scheduledSettings),
|
||||||
});
|
});
|
||||||
snackSuccess(t('backup.scheduleSaved'));
|
snackSuccess(t('backup.scheduleSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
savingSchedule = false;
|
savingSchedule = false;
|
||||||
}
|
}
|
||||||
@@ -258,8 +259,8 @@
|
|||||||
loadingFiles = true;
|
loadingFiles = true;
|
||||||
try {
|
try {
|
||||||
backupFiles = await api<BackupFile[]>('/backup/files');
|
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
loadingFiles = false;
|
loadingFiles = false;
|
||||||
}
|
}
|
||||||
@@ -275,8 +276,8 @@
|
|||||||
a.download = filename;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +287,8 @@
|
|||||||
snackSuccess(t('backup.fileDeleted'));
|
snackSuccess(t('backup.fileDeleted'));
|
||||||
confirmDeleteFile = '';
|
confirmDeleteFile = '';
|
||||||
await refreshFiles();
|
await refreshFiles();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { setup } from '$lib/auth.svelte';
|
import { setup } from '$lib/auth.svelte';
|
||||||
|
import { errMsg } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { initTheme } from '$lib/theme.svelte';
|
import { initTheme } from '$lib/theme.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
try {
|
try {
|
||||||
await setup(username, password);
|
await setup(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
|
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { page } from '$app/state';
|
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 BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
import ReceiverSection from './ReceiverSection.svelte';
|
import ReceiverSection from './ReceiverSection.svelte';
|
||||||
import BotGroupHeader from './BotGroupHeader.svelte';
|
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ──── Helpers ────
|
||||||
|
|
||||||
function getBotName(target: NotificationTarget): string | null {
|
function getBotName(target: NotificationTarget): string | null {
|
||||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
return recv.receiver_key || '?';
|
return recv.receiver_key || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──
|
// ──── Constants ────
|
||||||
|
|
||||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||||
type TargetType = typeof ALL_TYPES[number];
|
type TargetType = typeof ALL_TYPES[number];
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
return tiles;
|
return tiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Derived state ──
|
// ──── Derived state ────
|
||||||
|
|
||||||
let allTargets = $derived(targetsCache.items);
|
let allTargets = $derived(targetsCache.items);
|
||||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
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 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 })));
|
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 showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receiver inline form state ──
|
// ──── Receiver inline form state ────
|
||||||
|
|
||||||
let addingReceiverForTarget = $state<number | null>(null);
|
let addingReceiverForTarget = $state<number | null>(null);
|
||||||
let receiverForm = $state<Record<string, any>>({});
|
let receiverForm = $state<Record<string, any>>({});
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Effects ──
|
// ──── Effects ────
|
||||||
|
|
||||||
// Reset form when switching target type tabs
|
// Reset form when switching target type tabs
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -239,11 +239,11 @@
|
|||||||
addingReceiverForTarget = null;
|
addingReceiverForTarget = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Data loading ──
|
// ──── Data loading ────
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
// ── Bot grouping ──
|
// ──── Bot grouping ────
|
||||||
|
|
||||||
type TargetGroup = {
|
type TargetGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -355,8 +355,8 @@
|
|||||||
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('common.loadError');
|
loadError = errMsg(err, t('common.loadError'));
|
||||||
snackError(loadError);
|
snackError(loadError);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Target CRUD ──
|
// ──── Target CRUD ────
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
@@ -475,9 +475,10 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetSaved'));
|
snackSuccess(t('snack.targetSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
@@ -488,7 +489,7 @@
|
|||||||
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(`Failed: ${res.error}`);
|
else snackError(`Failed: ${res.error}`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
@@ -497,15 +498,16 @@
|
|||||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetDeleted'));
|
snackSuccess(t('snack.targetDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receiver CRUD ──
|
// ──── Receiver CRUD ────
|
||||||
|
|
||||||
async function openReceiverForm(targetId: number, targetType: string) {
|
async function openReceiverForm(targetId: number, targetType: string) {
|
||||||
// Force a remount of any picker palette when the same target is reopened
|
// Force a remount of any picker palette when the same target is reopened
|
||||||
@@ -575,8 +577,8 @@
|
|||||||
addingReceiverForTarget = null;
|
addingReceiverForTarget = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('targets.receiverAdded'));
|
snackSuccess(t('targets.receiverAdded'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
receiverSubmitting = false;
|
receiverSubmitting = false;
|
||||||
}
|
}
|
||||||
@@ -590,7 +592,7 @@
|
|||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
|
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) {
|
async function removeReceiver(targetId: number, receiverId: number) {
|
||||||
@@ -598,7 +600,7 @@
|
|||||||
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('targets.receiverDeleted'));
|
snackSuccess(t('targets.receiverDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBroadcastChild(targetId: number, childId: number) {
|
async function toggleBroadcastChild(targetId: number, childId: number) {
|
||||||
@@ -613,7 +615,7 @@
|
|||||||
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testReceiver(targetId: number, receiverId: number) {
|
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' });
|
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(`Failed: ${res.error}`);
|
else snackError(`Failed: ${res.error}`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
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 { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
supportedLocalesCache.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(); }
|
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||||
showForm = false; editing = null; await load();
|
showForm = false; editing = null; await load();
|
||||||
snackSuccess(t('snack.templateSaved'));
|
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();
|
refreshAllPreviews();
|
||||||
}
|
}
|
||||||
snackSuccess(t('templateConfig.resetApplied'));
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,10 +472,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@
|
|||||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||||
{:else}
|
{: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}
|
||||||
{/if}
|
{/if}
|
||||||
</CollapsibleSlot>
|
</CollapsibleSlot>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
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 { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -158,8 +158,8 @@
|
|||||||
error: res?.error || '',
|
error: res?.error || '',
|
||||||
locale,
|
locale,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
previewModal = { slotName, rendered: '', error: err.message, locale };
|
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading = false;
|
previewLoading = false;
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
});
|
});
|
||||||
async function load() {
|
async function load() {
|
||||||
try { await trackingConfigsCache.fetch(true); }
|
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(); }
|
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||||
showForm = false; editing = null; await load();
|
showForm = false; editing = null; await load();
|
||||||
snackSuccess(t('snack.trackingConfigSaved'));
|
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);
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
@@ -294,10 +294,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,227 +1,225 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, parseDate } from '$lib/api';
|
import { api, parseDate , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { getAuth } from '$lib/auth.svelte';
|
import { getAuth } from '$lib/auth.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { User } from '$lib/types';
|
import type { User } from '$lib/types';
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
let users = $state<User[]>([]);
|
let users = $state<User[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let form = $state({ username: '', password: '', role: 'user' });
|
let form = $state({ username: '', password: '', role: 'user' });
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
|
|
||||||
// Admin reset password
|
// Admin reset password
|
||||||
let resetUserId = $state<number | null>(null);
|
let resetUserId = $state<number | null>(null);
|
||||||
let resetUsername = $state('');
|
let resetUsername = $state('');
|
||||||
let resetPassword = $state('');
|
let resetPassword = $state('');
|
||||||
let resetMsg = $state('');
|
let resetMsg = $state('');
|
||||||
let resetSuccess = $state(false);
|
let resetSuccess = $state(false);
|
||||||
|
|
||||||
// Admin edit username/role
|
// Admin edit username/role
|
||||||
let editUserId = $state<number | null>(null);
|
let editUserId = $state<number | null>(null);
|
||||||
let editUsername = $state('');
|
let editUsername = $state('');
|
||||||
let editRole = $state('user');
|
let editRole = $state('user');
|
||||||
let editMsg = $state('');
|
let editMsg = $state('');
|
||||||
let editSuccess = $state(false);
|
let editSuccess = $state(false);
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try { users = await api('/users'); }
|
try { users = await api('/users'); }
|
||||||
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; }
|
finally { loaded = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create(e: SubmitEvent) {
|
async function create(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
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')); }
|
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); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
}
|
||||||
}
|
function remove(id: number) {
|
||||||
function remove(id: number) {
|
confirmDelete = {
|
||||||
confirmDelete = {
|
id,
|
||||||
id,
|
onconfirm: async () => {
|
||||||
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); }
|
||||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
finally { confirmDelete = null; }
|
||||||
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 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;
|
||||||
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;
|
||||||
async function saveUserEdit(e: SubmitEvent) {
|
try {
|
||||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||||
try {
|
editMsg = t('snack.userUpdated');
|
||||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
editSuccess = true;
|
||||||
editMsg = t('snack.userUpdated');
|
snackSuccess(editMsg);
|
||||||
editSuccess = true;
|
await load();
|
||||||
snackSuccess(editMsg);
|
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||||
await load();
|
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
|
||||||
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;
|
||||||
async function resetUserPassword(e: SubmitEvent) {
|
try {
|
||||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||||
try {
|
resetMsg = t('common.passwordChanged');
|
||||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
resetSuccess = true;
|
||||||
resetMsg = t('common.passwordChanged');
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
resetSuccess = true;
|
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
|
||||||
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[] = [];
|
||||||
function userTiles(user: User): MetaTile[] {
|
const isAdmin = user.role === 'admin';
|
||||||
const tiles: MetaTile[] = [];
|
tiles.push({
|
||||||
const isAdmin = user.role === 'admin';
|
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
||||||
tiles.push({
|
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
||||||
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
tone: isAdmin ? 'orchid' : 'sky',
|
||||||
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
});
|
||||||
tone: isAdmin ? 'orchid' : 'sky',
|
tiles.push({
|
||||||
});
|
icon: 'mdiCalendarOutline',
|
||||||
tiles.push({
|
label: parseDate(user.created_at).toLocaleDateString(),
|
||||||
icon: 'mdiCalendarOutline',
|
hint: t('users.joined'),
|
||||||
label: parseDate(user.created_at).toLocaleDateString(),
|
tone: 'lavender',
|
||||||
hint: t('users.joined'),
|
mono: true,
|
||||||
tone: 'lavender',
|
});
|
||||||
mono: true,
|
if (user.id === auth.user?.id) {
|
||||||
});
|
tiles.push({
|
||||||
if (user.id === auth.user?.id) {
|
icon: 'mdiAccountStar',
|
||||||
tiles.push({
|
label: t('users.you', 'you'),
|
||||||
icon: 'mdiAccountStar',
|
tone: 'mint',
|
||||||
label: t('users.you', 'you'),
|
});
|
||||||
tone: 'mint',
|
}
|
||||||
});
|
return tiles;
|
||||||
}
|
}
|
||||||
return tiles;
|
</script>
|
||||||
}
|
|
||||||
</script>
|
<PageHeader
|
||||||
|
title={t('users.title')}
|
||||||
<PageHeader
|
emphasis={t('users.titleEmphasis')}
|
||||||
title={t('users.title')}
|
description={t('users.description')}
|
||||||
emphasis={t('users.titleEmphasis')}
|
crumb={t('crumbs.systemAccess')}
|
||||||
description={t('users.description')}
|
count={users.length}
|
||||||
crumb={t('crumbs.systemAccess')}
|
countLabel={t('users.countLabel')}
|
||||||
count={users.length}
|
>
|
||||||
countLabel={t('users.countLabel')}
|
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||||
>
|
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||||
<Button size="sm" onclick={() => showForm = !showForm}>
|
</Button>
|
||||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
</PageHeader>
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
{#if showForm}
|
||||||
|
<Card class="mb-6">
|
||||||
{#if showForm}
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
<Card class="mb-6">
|
<form onsubmit={create} class="space-y-3">
|
||||||
{#if error}<ErrorBanner message={error} />{/if}
|
<div>
|
||||||
<form onsubmit={create} class="space-y-3">
|
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||||
<div>
|
<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)]" />
|
||||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
</div>
|
||||||
<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>
|
||||||
<div>
|
<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)]" />
|
||||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
</div>
|
||||||
<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>
|
||||||
<div>
|
<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)]">
|
||||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
<option value="user">{t('users.roleUser')}</option>
|
||||||
<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="admin">{t('users.roleAdmin')}</option>
|
||||||
<option value="user">{t('users.roleUser')}</option>
|
</select>
|
||||||
<option value="admin">{t('users.roleAdmin')}</option>
|
</div>
|
||||||
</select>
|
<Button type="submit">{t('users.create')}</Button>
|
||||||
</div>
|
</form>
|
||||||
<Button type="submit">{t('users.create')}</Button>
|
</Card>
|
||||||
</form>
|
{/if}
|
||||||
</Card>
|
|
||||||
{/if}
|
{#if users.length === 0}
|
||||||
|
<Card>
|
||||||
{#if users.length === 0}
|
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||||
<Card>
|
</Card>
|
||||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
{:else}
|
||||||
</Card>
|
<div class="list-stack stagger-children">
|
||||||
{:else}
|
{#each users as user}
|
||||||
<div class="list-stack stagger-children">
|
<Card hover>
|
||||||
{#each users as user}
|
<div class="list-row">
|
||||||
<Card hover>
|
<div class="list-row__identity">
|
||||||
<div class="list-row">
|
<p class="font-medium truncate">{user.username}</p>
|
||||||
<div class="list-row__identity">
|
<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>
|
||||||
<p class="font-medium truncate">{user.username}</p>
|
</div>
|
||||||
<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>
|
<MetaStrip tiles={userTiles(user)} />
|
||||||
</div>
|
<div class="list-row__actions">
|
||||||
<MetaStrip tiles={userTiles(user)} />
|
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||||
<div class="list-row__actions">
|
{#if user.id !== auth.user?.id}
|
||||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||||
{#if user.id !== auth.user?.id}
|
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
{/if}
|
||||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
{/each}
|
||||||
</Card>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
<!-- Admin reset password modal -->
|
||||||
|
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||||
<!-- Admin reset password modal -->
|
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
<div>
|
||||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
<div>
|
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
</div>
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
{#if resetMsg}
|
||||||
</div>
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||||
{#if resetMsg}
|
{/if}
|
||||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
<Button type="submit" class="w-full">
|
||||||
{/if}
|
{t('common.save')}
|
||||||
<Button type="submit" class="w-full">
|
</Button>
|
||||||
{t('common.save')}
|
</form>
|
||||||
</Button>
|
</Modal>
|
||||||
</form>
|
|
||||||
</Modal>
|
<!-- Admin edit username/role modal -->
|
||||||
|
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||||
<!-- Admin edit username/role modal -->
|
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
<div>
|
||||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||||
<div>
|
<input id="edit-username" bind:value={editUsername} required
|
||||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
<input id="edit-username" bind:value={editUsername} required
|
</div>
|
||||||
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>
|
||||||
<div>
|
<select id="edit-role" bind:value={editRole}
|
||||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||||
<select id="edit-role" bind:value={editRole}
|
<option value="user">{t('users.roleUser')}</option>
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
<option value="admin">{t('users.roleAdmin')}</option>
|
||||||
<option value="user">{t('users.roleUser')}</option>
|
</select>
|
||||||
<option value="admin">{t('users.roleAdmin')}</option>
|
</div>
|
||||||
</select>
|
{#if editMsg}
|
||||||
</div>
|
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||||
{#if editMsg}
|
{/if}
|
||||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||||
{/if}
|
</form>
|
||||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
</Modal>
|
||||||
</form>
|
|
||||||
</Modal>
|
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ class EventType(str, Enum):
|
|||||||
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
||||||
UPS_OVERLOAD = "ups_overload"
|
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
|
@dataclass
|
||||||
class ServiceEvent:
|
class ServiceEvent:
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ class NotificationDispatcher:
|
|||||||
# Optional shared session owned by the caller; when supplied we reuse
|
# Optional shared session owned by the caller; when supplied we reuse
|
||||||
# its connection pool instead of opening a fresh per-dispatch session.
|
# its connection pool instead of opening a fresh per-dispatch session.
|
||||||
self._shared_session = 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
|
@contextlib.asynccontextmanager
|
||||||
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
||||||
@@ -198,20 +204,49 @@ class NotificationDispatcher:
|
|||||||
def _message_for_receiver(
|
def _message_for_receiver(
|
||||||
self, receiver: Receiver, default_message: str,
|
self, receiver: Receiver, default_message: str,
|
||||||
event: ServiceEvent, target: TargetConfig,
|
event: ServiceEvent, target: TargetConfig,
|
||||||
|
cache: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if receiver.locale and receiver.locale != target.locale:
|
"""Render message respecting receiver locale, with optional cache.
|
||||||
return self._render_message(event, target, receiver.locale)
|
|
||||||
return default_message
|
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(
|
async def _send_to_target(
|
||||||
self, event: ServiceEvent, target: TargetConfig
|
self, event: ServiceEvent, target: TargetConfig
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
default_message = self._render_message(event, target, target.locale)
|
||||||
send_method = _PROVIDER_HANDLERS.get(target.type)
|
send_method = _PROVIDER_HANDLERS.get(target.type)
|
||||||
if send_method is None:
|
if send_method is None:
|
||||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
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)
|
# Asset preload (Telegram-specific)
|
||||||
@@ -352,7 +387,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
||||||
return {"success": False, "error": "Invalid telegram receiver"}
|
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(
|
text_result = await client.send_message(
|
||||||
chat_id=receiver.chat_id,
|
chat_id=receiver.chat_id,
|
||||||
text=message,
|
text=message,
|
||||||
@@ -407,7 +442,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||||
return {"success": False, "error": "Invalid webhook receiver"}
|
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 = {
|
payload = {
|
||||||
"message": message,
|
"message": message,
|
||||||
"event_type": event.event_type.value,
|
"event_type": event.event_type.value,
|
||||||
@@ -450,7 +485,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
||||||
return {"success": False, "error": "Invalid email receiver"}
|
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
|
# body_html=None lets EmailClient build a safely-escaped HTML
|
||||||
# alternative from body_text instead of trusting user content.
|
# alternative from body_text instead of trusting user content.
|
||||||
return await email_client.send(
|
return await email_client.send(
|
||||||
@@ -479,7 +514,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||||
return {"success": False, "error": "Invalid discord receiver"}
|
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)
|
return await client.send(receiver.webhook_url, message, username=username)
|
||||||
|
|
||||||
results = await self._fan_out(target.receivers, send_one)
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
@@ -501,7 +536,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||||
return {"success": False, "error": "Invalid slack receiver"}
|
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)
|
return await client.send(receiver.webhook_url, message, username=username)
|
||||||
|
|
||||||
results = await self._fan_out(target.receivers, send_one)
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
@@ -530,7 +565,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||||
return {"success": False, "error": "Invalid ntfy receiver"}
|
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(
|
return await client.send(
|
||||||
server_url, receiver.topic, message,
|
server_url, receiver.topic, message,
|
||||||
title=title, priority=receiver.priority, auth_token=auth_token,
|
title=title, priority=receiver.priority, auth_token=auth_token,
|
||||||
@@ -563,7 +598,7 @@ class NotificationDispatcher:
|
|||||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||||
return {"success": False, "error": "Invalid matrix receiver"}
|
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
|
# body_html is the same plain text — Matrix accepts the
|
||||||
# raw message as both ``body`` and ``formatted_body``.
|
# raw message as both ``body`` and ``formatted_body``.
|
||||||
# If templates emit HTML in the future, generate a
|
# If templates emit HTML in the future, generate a
|
||||||
|
|||||||
@@ -222,21 +222,48 @@ class TelegramClient:
|
|||||||
"""SSRF-guarded GET that returns ``(data, error)``.
|
"""SSRF-guarded GET that returns ``(data, error)``.
|
||||||
|
|
||||||
Validates the URL via ``avalidate_outbound_url`` before any HTTP
|
Validates the URL via ``avalidate_outbound_url`` before any HTTP
|
||||||
traffic. Errors are returned (not raised) and stripped of any
|
traffic. Redirects are walked manually so each ``Location`` is
|
||||||
embedded secrets before they propagate to the operator-visible
|
re-validated — without this an attacker-controlled origin could
|
||||||
result dict.
|
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:
|
try:
|
||||||
await avalidate_outbound_url(url)
|
await avalidate_outbound_url(current_url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return None, f"Unsafe URL: {redact_exc(err)}"
|
return None, f"Unsafe URL: {redact_exc(err)}"
|
||||||
try:
|
try:
|
||||||
async with self._session.get(
|
for _ in range(max_redirects + 1):
|
||||||
url, headers=headers or {}, timeout=_DOWNLOAD_TIMEOUT,
|
async with self._session.get(
|
||||||
) as resp:
|
current_url,
|
||||||
if resp.status != 200:
|
headers=headers or {},
|
||||||
return None, f"HTTP {resp.status}"
|
timeout=_DOWNLOAD_TIMEOUT,
|
||||||
return await resp.read(), None
|
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:
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||||
return None, redact_exc(err)
|
return None, redact_exc(err)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
@@ -21,6 +21,14 @@ class ServiceProviderType(str, Enum):
|
|||||||
NUT = "nut"
|
NUT = "nut"
|
||||||
GOOGLE_PHOTOS = "google_photos"
|
GOOGLE_PHOTOS = "google_photos"
|
||||||
WEBHOOK = "webhook"
|
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):
|
class ServiceProvider(ABC):
|
||||||
@@ -28,10 +36,27 @@ class ServiceProvider(ABC):
|
|||||||
|
|
||||||
A service provider connects to an external service (e.g., Immich photo server)
|
A service provider connects to an external service (e.g., Immich photo server)
|
||||||
and can poll for changes, producing generic ServiceEvent objects.
|
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
|
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
|
@abstractmethod
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Connect to the service and verify connectivity.
|
"""Connect to the service and verify connectivity.
|
||||||
@@ -59,6 +84,27 @@ class ServiceProvider(ABC):
|
|||||||
Tuple of (list of events detected, updated state dict).
|
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
|
@abstractmethod
|
||||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
"""Return the template variables this provider makes available."""
|
"""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
|
# Registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -456,6 +583,8 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
|||||||
"nut": NUT_CAPABILITIES,
|
"nut": NUT_CAPABILITIES,
|
||||||
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
||||||
"webhook": WEBHOOK_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
|
# 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.
|
# raw api_key out of dict keys in case of a memory dump.
|
||||||
_USERS_CACHE_TTL_SECONDS = 3600
|
_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]]] = {}
|
_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:
|
def _users_cache_key(url: str, api_key: str) -> str:
|
||||||
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
||||||
return digest[:32]
|
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:
|
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||||
return entry[1]
|
return entry[1]
|
||||||
|
|
||||||
async with _users_cache_lock:
|
async with _get_users_cache_lock():
|
||||||
# Re-check after acquiring the lock — another coroutine may have
|
# Re-check after acquiring the lock — another coroutine may have
|
||||||
# refreshed the entry while we waited.
|
# refreshed the entry while we waited.
|
||||||
entry = _users_cache.get(key)
|
entry = _users_cache.get(key)
|
||||||
|
|||||||
@@ -200,10 +200,28 @@ class NutServiceProvider(ServiceProvider):
|
|||||||
try:
|
try:
|
||||||
for ups_name in collection_ids:
|
for ups_name in collection_ids:
|
||||||
prev = tracker_state.get(ups_name, {})
|
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:
|
try:
|
||||||
variables = await client.list_var(ups_name)
|
variables = await client.list_var(ups_name)
|
||||||
data = NutUpsData.from_variables(ups_name, variables)
|
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
|
# Check for comms restored
|
||||||
if not prev.get("comms_ok", True):
|
if not prev.get("comms_ok", True):
|
||||||
events.append(self._make_event(
|
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 example slots
|
||||||
"usage_latest", "usage_search", "usage_random",
|
"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
|
# Backward-compatible aliases
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Краткая однострочная сводка состояния
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Показать доступные команды
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Сбросить счётчик сбоев (tracker:<id>, target:<id> или all)
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Показать счётчики состояния моста
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Показать настроенные пороги оповещений
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user