Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7c48b06ee | |||
| 66f152ef2c | |||
| faaaa39f8a | |||
| 8651767112 | |||
| 10d30fc956 | |||
| 22127e2a59 | |||
| 90f958bdc6 | |||
| dec0839853 | |||
| dfd7329177 | |||
| ba199f24bd | |||
| bb5afcc222 | |||
| 4335036c22 |
@@ -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
|
||||
run: |
|
||||
cd frontend
|
||||
npm run check || echo "::warning::svelte-check reported warnings"
|
||||
npm run check
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
test-backend:
|
||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# Editable installs of packages/core + packages/server are extremely slow
|
||||
# on the hosted runner — measured 4-6x slower than building wheels first
|
||||
# because hatchling's editable hook re-resolves on every collection. We
|
||||
# build wheels once into an isolated venv, then install them (and only
|
||||
# the test deps). The venv isolation also prevents broken-wheel installs
|
||||
# from leaking dist-info across runs on the persistent Gitea runner
|
||||
# (pip can't uninstall a wheel that landed without a RECORD file). The
|
||||
# wheels themselves are NOT cached because their hashes depend on every
|
||||
# file under packages/ — invalidates on basically every PR. Pip's HTTP
|
||||
# cache for the test deps is enough.
|
||||
- name: Build wheels in isolated venv
|
||||
run: |
|
||||
python -m venv /tmp/venv
|
||||
/tmp/venv/bin/pip install --upgrade pip build
|
||||
mkdir -p /tmp/wheels
|
||||
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
|
||||
|
||||
- name: Install backend + test deps
|
||||
run: |
|
||||
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
|
||||
|
||||
- name: Run pytest
|
||||
env:
|
||||
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
|
||||
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
NOTIFY_BRIDGE_DEBUG: "false"
|
||||
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
|
||||
run: |
|
||||
cd packages/server
|
||||
/tmp/venv/bin/pytest tests --tb=short
|
||||
|
||||
build-image:
|
||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||
needs: [test-frontend]
|
||||
needs: [test-frontend, test-backend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -10,7 +10,43 @@ env:
|
||||
IMAGE_NAME: alexei.dolgolyov/notify-bridge
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# Wheel-first strategy in an isolated venv — editable install is too slow,
|
||||
# and a plain pip install into the toolcache Python leaks state across
|
||||
# runs on the persistent Gitea runner (previous broken wheel installs
|
||||
# leave dist-info dirs that pip can't uninstall: "uninstall-no-record-file").
|
||||
- name: Build wheels in isolated venv
|
||||
run: |
|
||||
python -m venv /tmp/venv
|
||||
/tmp/venv/bin/pip install --upgrade pip build
|
||||
mkdir -p /tmp/wheels
|
||||
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
|
||||
|
||||
- name: Install backend + test deps
|
||||
run: |
|
||||
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
|
||||
|
||||
- name: Run pytest
|
||||
env:
|
||||
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
|
||||
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
NOTIFY_BRIDGE_DEBUG: "false"
|
||||
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
|
||||
run: |
|
||||
cd packages/server
|
||||
/tmp/venv/bin/pytest tests --tb=short
|
||||
|
||||
release:
|
||||
needs: [test-backend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
# Operations Guide
|
||||
|
||||
This document covers running, monitoring, and recovering Notify Bridge in
|
||||
production. The intended audience is the operator on call when the
|
||||
notifications stop firing or when a release upgrade goes sideways.
|
||||
|
||||
For developer-focused docs (architecture, conventions, project layout) see
|
||||
`CLAUDE.md` and the `.claude/docs/` directory.
|
||||
|
||||
## Deployment overview
|
||||
|
||||
Notify Bridge ships as a single Docker image. All state lives in a single
|
||||
data directory mounted at `/data`.
|
||||
|
||||
### Required environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `NOTIFY_BRIDGE_SECRET_KEY` | _(none)_ | **Required.** 32+ random bytes. The server refuses to boot with the default placeholder or any of the known dev literals. |
|
||||
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | `http://localhost:5175` | Comma-separated list. `*` is rejected because credentials are enabled. |
|
||||
| `NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse-proxy IP. |
|
||||
|
||||
### Useful environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `NOTIFY_BRIDGE_DATA_DIR` | `/data` | Where the SQLite DB, snapshots, and backups live. |
|
||||
| `NOTIFY_BRIDGE_DATABASE_URL` | _(derived from data_dir)_ | Override only if you want a non-default DB path. |
|
||||
| `NOTIFY_BRIDGE_DEBUG` | `false` | Verbose logging + SQL echo. Do not enable in production. |
|
||||
| `NOTIFY_BRIDGE_LOG_FORMAT` | `text` | Set to `json` for one JSON object per line — pipe to a log aggregator. |
|
||||
| `NOTIFY_BRIDGE_LOG_LEVEL` | `INFO` | Root logger level. |
|
||||
| `NOTIFY_BRIDGE_LOG_LEVELS` | _(empty)_ | Per-module overrides, e.g. `sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG`. |
|
||||
| `NOTIFY_BRIDGE_EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history kept by the daily cleanup job. `0` disables retention. |
|
||||
| `NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots retained. `0` disables snapshotting. |
|
||||
| `NOTIFY_BRIDGE_METRICS_ENABLED` | `true` | Expose `/api/metrics` for Prometheus. Set to `false` if the API port crosses a trust boundary. |
|
||||
| `NOTIFY_BRIDGE_GRACEFUL_SHUTDOWN_SECONDS` | `60` | SIGTERM grace period before in-flight requests are killed. |
|
||||
| `NOTIFY_BRIDGE_SUPERVISED` | _(auto)_ | Force the supervised flag for `apply-restart`. Use `true` when running under systemd/PM2 outside Docker. |
|
||||
|
||||
### Data directory layout
|
||||
|
||||
```
|
||||
/data/
|
||||
notify_bridge.db # main SQLite DB (WAL mode)
|
||||
notify_bridge.db-wal # SQLite write-ahead log
|
||||
notify_bridge.db-shm # SQLite shared memory file
|
||||
backups/
|
||||
pre-migrate-*.db # automatic pre-upgrade snapshots
|
||||
backup-*.json # scheduled / manual config backups
|
||||
snapshots/ # legacy alias retained for older deployments
|
||||
pending_restore.json # staged restore (consumed at next boot)
|
||||
applied_restores/ # archive of applied restore payloads
|
||||
```
|
||||
|
||||
Always mount `/data` on a persistent volume. The WAL files MUST live on the
|
||||
same filesystem as the main DB — never split them across mounts.
|
||||
|
||||
### Docker example
|
||||
|
||||
See `docker-compose.yml` at the repo root for the canonical reference. The
|
||||
container runs read-only with `tmpfs` for `/tmp`, drops all capabilities,
|
||||
and limits memory/CPU. The healthcheck targets `/api/ready` (deep) — see
|
||||
the next section.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
Two endpoints, used for different probe types.
|
||||
|
||||
### `GET /api/health` — liveness, shallow
|
||||
|
||||
Returns `200 OK` once the ASGI app has started. Does not touch the DB or
|
||||
the scheduler. Use this for liveness probes that should only restart the
|
||||
process if it stops responding entirely.
|
||||
|
||||
```json
|
||||
{"status": "ok", "version": "0.8.0"}
|
||||
```
|
||||
|
||||
### `GET /api/ready` — readiness, deep
|
||||
|
||||
Verifies that each critical dependency is reachable:
|
||||
|
||||
* **db** — `SELECT 1` against the SQLAlchemy engine, 2-second timeout.
|
||||
* **scheduler** — APScheduler `running` flag.
|
||||
* **ha** — Home Assistant subscription supervisor task. Reported as
|
||||
`na` when no HA providers are configured, `ok` when at least one
|
||||
supervisor is alive, `degraded` otherwise. **Informational only** —
|
||||
HA degradation does not flip readiness off.
|
||||
|
||||
Returns `503` when any required check (db, scheduler) fails.
|
||||
|
||||
```json
|
||||
{
|
||||
"ready": true,
|
||||
"checks": {"db": "ok", "scheduler": "ok", "ha": "na"},
|
||||
"errors": [],
|
||||
"version": "0.8.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Kubernetes probe example
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 8420
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/ready
|
||||
port: 8420
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
```
|
||||
|
||||
The Docker compose file uses `/api/ready` as its healthcheck so the
|
||||
container is only reported healthy after migrations finish.
|
||||
|
||||
## Metrics
|
||||
|
||||
Notify Bridge exposes Prometheus metrics at `GET /api/metrics` in the
|
||||
standard text exposition format. **No authentication** — Prometheus
|
||||
scrapers do not authenticate. Disable via `NOTIFY_BRIDGE_METRICS_ENABLED=false`
|
||||
when the API port is reachable beyond the trust boundary.
|
||||
|
||||
### Prometheus scrape example
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: notify-bridge
|
||||
metrics_path: /api/metrics
|
||||
static_configs:
|
||||
- targets: ['notify-bridge.internal:8420']
|
||||
scrape_interval: 30s
|
||||
```
|
||||
|
||||
### Available metrics
|
||||
|
||||
| Metric | Type | Labels | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `notify_bridge_deferred_pending` | Gauge | _(none)_ | Pending rows in `deferred_dispatch`. Refreshed on each scrape. A persistent non-zero value usually means a tracker target is in extended quiet hours. |
|
||||
| `notify_bridge_event_log_total` | Counter | `status`, `event_type` | Events written to `event_log`. `status` is the dispatch outcome (`dispatched`, `dropped`, `deferred`, etc.). |
|
||||
| `notify_bridge_dispatch_duration_seconds` | Histogram | `channel` | Wall-clock duration of one outbound dispatch (Telegram, Discord, email, …). Useful for latency alerts. |
|
||||
| `notify_bridge_provider_poll_failures_total` | Counter | `provider_type` | Polling provider tick failures (Immich poll error, Gitea API down, …). Compare against expected scan interval to compute failure rate. |
|
||||
| `notify_bridge_target_send_failures_total` | Counter | `target_type`, `status_code` | Failed sends to a notification channel. `status_code` is the HTTP status (or `0` when no HTTP response was received). |
|
||||
|
||||
The metrics module never imports `prometheus_client` outside `api/metrics.py`.
|
||||
Other modules record events through the `metrics` singleton — see that
|
||||
module's docstring before adding new collectors.
|
||||
|
||||
## Backups
|
||||
|
||||
Notify Bridge produces three different kinds of backup files. Know which
|
||||
one you are looking at before restoring.
|
||||
|
||||
| Kind | Location | Format | Trigger |
|
||||
| --- | --- | --- | --- |
|
||||
| Config backup | `data/backups/backup-*.json` | JSON (BackupFile schema) | Manual via `/api/backup/files` POST or scheduled job |
|
||||
| Pre-migration snapshot | `data/backups/pre-migrate-*.db` | SQLite DB file | Automatic on every boot before migrations |
|
||||
| Pending restore | `data/pending_restore.json` | JSON | Staged via `/api/backup/prepare-restore`, consumed at next restart |
|
||||
|
||||
Config backups capture user configuration (providers, trackers, targets,
|
||||
templates, …). They do **not** include `event_log`, `deferred_dispatch`,
|
||||
or any other operational table. Pre-migration snapshots are full DB
|
||||
copies and contain everything.
|
||||
|
||||
### Manual backup
|
||||
|
||||
The admin UI has a one-click button under Settings → Backup. Equivalent
|
||||
HTTP call:
|
||||
|
||||
```bash
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude"
|
||||
```
|
||||
|
||||
The download endpoint produces a downloadable JSON envelope with no
|
||||
secrets unless `secrets_mode=include` is passed:
|
||||
|
||||
```bash
|
||||
curl -fsS -X GET \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
-OJ "https://notify-bridge.example.com/api/backup/export?secrets_mode=exclude"
|
||||
```
|
||||
|
||||
### Scheduled backup
|
||||
|
||||
Configure under Settings → Backup or via `PUT /api/backup/scheduled` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"backup_scheduled_enabled": "true",
|
||||
"backup_scheduled_interval_hours": "24",
|
||||
"backup_secrets_mode": "exclude",
|
||||
"backup_retention_count": "5"
|
||||
}
|
||||
```
|
||||
|
||||
Saved files land in `data/backups/`; retention prunes the oldest files
|
||||
beyond `backup_retention_count`. Backups can be downloaded individually:
|
||||
|
||||
```bash
|
||||
curl -fsS -X GET \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
"https://notify-bridge.example.com/api/backup/files/backup-2026-05-16T12-00-00.json" \
|
||||
-o backup-latest.json
|
||||
```
|
||||
|
||||
### Cron snippet for off-host backup
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/notify-bridge-backup
|
||||
0 3 * * * www-data \
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $(cat /etc/notify-bridge/admin.token)" \
|
||||
"https://notify-bridge.example.com/api/backup/files?secrets_mode=exclude" \
|
||||
-o /var/backups/notify-bridge/backup-$(date +\%F).json
|
||||
```
|
||||
|
||||
### Restore procedure
|
||||
|
||||
Restoring REPLACES configuration. Always export the current state first.
|
||||
|
||||
```bash
|
||||
# 1. Stage the backup file (validates and writes to data/pending_restore.json)
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
-F "file=@backup-2026-05-16T12-00-00.json" \
|
||||
"https://notify-bridge.example.com/api/backup/prepare-restore?conflict_mode=overwrite"
|
||||
|
||||
# 2. Trigger graceful restart so startup applies the staged restore.
|
||||
# Same-origin Origin/Referer is enforced — call from the admin UI when
|
||||
# possible, or from the same host. Requires the supervisor to respawn
|
||||
# the process (Docker restart policy, systemd, PM2, etc.).
|
||||
curl -fsS -X POST \
|
||||
-H "Origin: https://notify-bridge.example.com" \
|
||||
-H "Referer: https://notify-bridge.example.com/settings/backup" \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
"https://notify-bridge.example.com/api/backup/apply-restart"
|
||||
```
|
||||
|
||||
If the process is **not** supervised, `/api/backup/apply-restart` returns
|
||||
`409`. Restart the backend manually after staging — startup applies the
|
||||
pending restore on the next boot.
|
||||
|
||||
To cancel a staged restore before applying:
|
||||
|
||||
```bash
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: Bearer $ADMIN_JWT" \
|
||||
"https://notify-bridge.example.com/api/backup/pending-restore"
|
||||
```
|
||||
|
||||
### Recovery from a corrupted DB
|
||||
|
||||
If migrations crash on boot or the DB file is unreadable, roll back to a
|
||||
pre-migration snapshot:
|
||||
|
||||
```bash
|
||||
# Stop the backend, then
|
||||
cd /var/lib/docker/volumes/notify-bridge-data/_data
|
||||
ls -1t backups/pre-migrate-*.db | head -5 # pick the snapshot
|
||||
|
||||
cp notify_bridge.db notify_bridge.db.broken # keep the broken DB for forensics
|
||||
cp backups/pre-migrate-2026-05-16T11-58-30.db notify_bridge.db
|
||||
rm -f notify_bridge.db-wal notify_bridge.db-shm # WAL belongs to the broken file
|
||||
```
|
||||
|
||||
Restart the container. The startup snapshot will run again and capture
|
||||
the rolled-back state, so you have a clean recovery point if the next
|
||||
boot needs another rollback.
|
||||
|
||||
## Logs
|
||||
|
||||
* Output goes to **stderr only**. The Docker log driver captures it.
|
||||
* Set `NOTIFY_BRIDGE_LOG_FORMAT=json` for line-delimited JSON suitable
|
||||
for Loki, ELK, or CloudWatch.
|
||||
* Secret values (bot tokens, API keys, passwords) are masked at the log
|
||||
formatter level — see `notify_bridge_server.logging_setup`.
|
||||
* No file rotation is built in. Use the Docker JSON log driver's
|
||||
`max-size`/`max-file` options or send logs to your aggregator.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml snippet
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
```
|
||||
|
||||
## Common operational scenarios
|
||||
|
||||
### "Notifications stopped firing"
|
||||
|
||||
1. Hit `/api/ready`. If `scheduler` is `fail`, restart the backend; the
|
||||
scheduler died in a way it cannot recover from.
|
||||
2. Check `notify_bridge_deferred_pending`. A non-zero value during quiet
|
||||
hours is normal; a value that grows monotonically across days is a
|
||||
bug — inspect the `deferred_dispatch` table.
|
||||
3. Inspect the most recent `event_log` rows in the admin Events page or:
|
||||
|
||||
```sql
|
||||
SELECT created_at, event_type, dispatch_status, details
|
||||
FROM event_log
|
||||
ORDER BY created_at DESC LIMIT 50;
|
||||
```
|
||||
|
||||
Look for a `dispatch_status` other than `dispatched`.
|
||||
4. If a single tracker is silent, verify the provider's last poll status
|
||||
in the admin UI (Providers page) — `notify_bridge_provider_poll_failures_total`
|
||||
tells you which provider type is failing.
|
||||
5. If you've configured a `bridge_self` tracker but never received a
|
||||
self-monitoring alert when something failed, see the next section —
|
||||
`bridge_self` failures are deliberately log-only to prevent recursion.
|
||||
|
||||
### Bridge self-monitoring is log-only on its own failures
|
||||
|
||||
The built-in `bridge_self` provider emits notifications when polls,
|
||||
dispatches, or target sends fail. To prevent infinite-recursion (a
|
||||
`bridge_self` notification failing → triggering another `bridge_self`
|
||||
notification → ...), failures of `bridge_self` events themselves are
|
||||
**not** counted toward target-failure thresholds and are logged only.
|
||||
|
||||
If your `bridge_self` notifications stop arriving, it means the
|
||||
notification target you wired them to is itself failing. Grep stderr for:
|
||||
|
||||
```text
|
||||
bridge_self target-failure emission failed
|
||||
emit_bridge_self_event failed
|
||||
```
|
||||
|
||||
The fix is always at the target layer (Telegram bot blocked, Matrix
|
||||
homeserver down, SMTP credentials rotated). The bridge cannot tell you
|
||||
about its own outbound failure — that's what the operator's external
|
||||
monitoring (Prometheus alert on `notify_bridge_target_send_failures_total`)
|
||||
is for.
|
||||
|
||||
### "Webhook returns 500"
|
||||
|
||||
Inspect the `webhook_payload_log` table for the matching request:
|
||||
|
||||
```sql
|
||||
SELECT received_at, status_code, error_message, payload_excerpt
|
||||
FROM webhook_payload_log
|
||||
ORDER BY received_at DESC LIMIT 20;
|
||||
```
|
||||
|
||||
Common causes: payload schema change in the source service, a tracker
|
||||
referencing a deleted provider, a Jinja template that errors out (look
|
||||
for `template render failed` in logs).
|
||||
|
||||
### "Telegram bot rate-limited (429)"
|
||||
|
||||
The Telegram client implements exponential backoff with jitter on
|
||||
`Retry-After`. No operator action is required for transient throttling.
|
||||
If the rate-limit persists, check:
|
||||
|
||||
* The bot is being driven by multiple Notify Bridge instances pointing
|
||||
at the same chat (split-brain — only one instance should own a bot).
|
||||
* A template is producing very large messages (Telegram limits message
|
||||
size to 4096 chars). Look for `MessageTooLong` in the logs.
|
||||
|
||||
### "DB lock contention"
|
||||
|
||||
SQLite WAL mode and `busy_timeout=10000` make this rare. If you see
|
||||
`SQLITE_BUSY` in logs:
|
||||
|
||||
* Check for long-running transactions (most often a stuck migration).
|
||||
* Confirm the WAL files are on the same filesystem as the main DB —
|
||||
splitting them across mounts is a known cause.
|
||||
* Run `sqlite3 notify_bridge.db "PRAGMA wal_checkpoint(TRUNCATE);"` to
|
||||
flush the WAL. Safe to run while the backend is up.
|
||||
|
||||
## Upgrades
|
||||
|
||||
1. Pre-migration snapshot is taken automatically before any migration
|
||||
runs. The latest five snapshots are retained by default.
|
||||
2. Migrations are idempotent — re-running an upgrade is safe.
|
||||
3. If a migration fails, the snapshot from step 1 is the recovery point.
|
||||
See "Recovery from a corrupted DB" above.
|
||||
4. Always test major version upgrades in staging first. The upgrade flow
|
||||
is the same in staging: pull the new image, restart the container.
|
||||
|
||||
The release tag stream lives at the project Gitea / GitHub releases page.
|
||||
Release notes are written to `RELEASE_NOTES.md` for the upcoming version
|
||||
and copied into the Gitea release body by the `release.yml` workflow.
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
A generic bridge between service providers and notification targets.
|
||||
|
||||
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
|
||||
notifications to configurable targets (Telegram, webhooks) using customizable templates.
|
||||
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
|
||||
and internal scheduler) for changes and dispatches notifications to configurable targets
|
||||
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Service Providers** — Connectors to external services (Immich, more coming)
|
||||
- **Service Providers** — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
|
||||
- **Trackers** — Monitor specific collections within a provider for changes
|
||||
- **Tracking Configs** — Define what events to watch for and scheduling rules
|
||||
- **Notification Targets** — Where to send notifications (Telegram chats, webhook URLs)
|
||||
- **Notification Targets** — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
|
||||
- **Template Configs** — Jinja2 templates that format notifications per provider type
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
packages/
|
||||
core/ — Shared library: providers, models, notifications, templates
|
||||
server/ — FastAPI REST server with SQLite database
|
||||
@@ -31,6 +32,7 @@ docker run -d \
|
||||
-p 8420:8420 \
|
||||
-v notify-bridge-data:/data \
|
||||
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
|
||||
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
|
||||
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
|
||||
```
|
||||
|
||||
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| -------- | -------- | ------- | ----------- |
|
||||
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
|
||||
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
|
||||
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
|
||||
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
|
||||
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
|
||||
| `CORS_ALLOWED_ORIGINS` | Recommended | `http://localhost:5175` | Comma-separated browser origins. Wildcard `*` is **rejected** because credentials are enabled. Set this to the URL you load the UI from. |
|
||||
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
|
||||
| `DATABASE_URL` | No | `sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db` | Override DB connection string. |
|
||||
| `HOST` | No | `0.0.0.0` | Bind address. |
|
||||
| `PORT` | No | `8420` | Server listen port. |
|
||||
| `DEBUG` | No | `false` | Enable debug logging. |
|
||||
|
||||
Reverse proxy / network:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse proxy IP (e.g. `172.17.0.1` for the default Docker bridge). Use `*` only when the container is not directly internet-reachable. |
|
||||
| `EXTERNAL_URL` | — | Public base URL (e.g. `https://notify.example.com`). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
|
||||
| `ALLOW_PRIVATE_URLS` | unset | Set to `1` to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). **Do not enable on a publicly exposed instance.** |
|
||||
|
||||
Auth & tokens:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Lifetime of access JWTs. |
|
||||
| `REFRESH_TOKEN_EXPIRE_DAYS` | `30` | Lifetime of refresh tokens. |
|
||||
| `JWT_ISSUER` | `notify-bridge` | `iss` claim. |
|
||||
| `JWT_AUDIENCE` | `notify-bridge-api` | `aud` claim. |
|
||||
|
||||
Logging (all are also live-editable in the Settings page, except `log_format`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `LOG_LEVEL` | `INFO` | Root level: `DEBUG` / `INFO` / `WARNING` / `ERROR`. |
|
||||
| `LOG_FORMAT` | `text` | `text` or `json`. Switching requires a restart. |
|
||||
| `LOG_LEVELS` | — | Per-module overrides, e.g. `notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO`. |
|
||||
|
||||
Retention & maintenance:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history to keep. `0` disables the retention job. |
|
||||
| `PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots to keep in `<DATA_DIR>/backups/`. `0` disables snapshotting. |
|
||||
| `GRACEFUL_SHUTDOWN_SECONDS` | `60` | Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
|
||||
|
||||
Integrations & misc:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | — | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
|
||||
| `TIMEZONE` | `UTC` | IANA timezone (e.g. `Europe/Warsaw`) used by the scheduler. Also settable from the Settings page. |
|
||||
| `STATIC_DIR` | `/app/static` (in Docker) | Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
|
||||
| `SUPERVISED` | auto-detect | Set to `1` to tell the backup endpoint that an external supervisor will restart the process. |
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -58,12 +107,50 @@ services:
|
||||
volumes:
|
||||
- notify-bridge-data:/data
|
||||
environment:
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
|
||||
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
||||
# Comma-separated list of allowed browser origins. Wildcard `*` is
|
||||
# rejected on startup because credentials are enabled.
|
||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
|
||||
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
|
||||
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
|
||||
# docker bridge, or `*` only if the container is NOT reachable from the
|
||||
# public internet).
|
||||
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
|
||||
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
|
||||
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
|
||||
# enable on a publicly exposed instance.
|
||||
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
healthcheck:
|
||||
# Use /api/ready (not /api/health) so the container is only reported
|
||||
# healthy after migrations and the scheduler finish booting.
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
pids_limit: 256
|
||||
|
||||
volumes:
|
||||
notify-bridge-data:
|
||||
```
|
||||
|
||||
A ready-to-use `docker-compose.yml` lives at the repo root.
|
||||
|
||||
### Health & Readiness
|
||||
|
||||
- `GET /api/health` — process is up. Use for liveness probes.
|
||||
- `GET /api/ready` — migrations + scheduler have booted. Use for readiness probes and Docker `HEALTHCHECK` (as the compose example above does).
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
@@ -81,4 +168,48 @@ npm run dev
|
||||
|
||||
## Supported Providers
|
||||
|
||||
- **Immich** — Photo/video server with album change detection
|
||||
- **Immich** — Photo/video server with album change detection (polling)
|
||||
- **Gitea** — Git server with push / issue / PR / release events (webhook)
|
||||
- **Planka** — Kanban board with card / list / board events (webhook)
|
||||
- **NUT** — Network UPS Tools for battery / power events (polling)
|
||||
- **Google Photos** — Album change detection (polling)
|
||||
- **Generic Webhook** — Catch arbitrary JSON payloads and route them via templates (webhook)
|
||||
- **Scheduler** — Internal provider for time-based scheduled messages
|
||||
|
||||
## Supported Notification Targets
|
||||
|
||||
- **Telegram** — Bot API with rich formatting, media groups, and inline commands
|
||||
- **Discord** — Webhook-based delivery with embeds
|
||||
- **Slack** — Incoming webhooks with Block Kit formatting
|
||||
- **Matrix** — Homeserver delivery with HTML formatting
|
||||
- **ntfy** — Self-hostable push notifications
|
||||
- **Email** — SMTP with HTML / plain-text templates
|
||||
- **Generic Webhook** — POST custom JSON payloads to any URL
|
||||
|
||||
## Bot Commands
|
||||
|
||||
Telegram bots can serve interactive commands per provider. All commands use
|
||||
Jinja2 templates that you can customize from the **Command Templates** page.
|
||||
|
||||
| Provider | Commands |
|
||||
| -------- | -------- |
|
||||
| Immich | `/status` `/albums` `/events` `/summary` `/latest` `/memory` `/random` `/search` `/find` `/person` `/place` `/favorites` `/people` `/help` |
|
||||
| Gitea | `/status` `/repos` `/issues` `/prs` `/commits` `/help` |
|
||||
| Planka | `/status` `/boards` `/cards` `/lists` `/help` |
|
||||
| NUT | `/status` `/devices` `/battery` `/help` |
|
||||
| Google Photos | `/status` `/albums` `/latest` `/search` `/random` `/help` |
|
||||
| Generic Webhook | `/status` `/help` |
|
||||
|
||||
Every provider also responds to `/start`, and rate-limit / empty-result
|
||||
fallback messages are templated as well.
|
||||
|
||||
## Smart Actions
|
||||
|
||||
Beyond notifications, providers can run **actions** against the source service.
|
||||
Currently implemented:
|
||||
|
||||
- **Immich — Auto-Organize** — Automatically sort newly-detected assets into
|
||||
albums based on configurable rules. Each rule combines criteria (people in
|
||||
the photo, search query, favorites, date range) with a target album, and can
|
||||
create the album if it doesn't exist. Supports dry-run mode for previewing
|
||||
what would move before committing.
|
||||
|
||||
+103
-12
@@ -1,14 +1,104 @@
|
||||
# v0.7.2 (2026-05-11)
|
||||
# v0.8.1 (2026-05-16)
|
||||
|
||||
## Features
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
- Redesign settings/common with Aurora cassettes — refreshed identity, logging, Telegram, and cache-ledger sections with the new glass/cassette UI ([6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9))
|
||||
- Group targets by bot in the targets view and redesign backup settings ([a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad))
|
||||
- Add `/status` command handler for webhook providers ([bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928))
|
||||
- **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))
|
||||
|
||||
## Bug Fixes
|
||||
## User-facing changes
|
||||
|
||||
- Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
|
||||
### Features
|
||||
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **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))
|
||||
|
||||
---
|
||||
|
||||
## Development / Internal
|
||||
|
||||
### Architecture
|
||||
|
||||
- **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))
|
||||
- **`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))
|
||||
- **Provider construction switched from if/elif ladder to factory registry** ([10d30fc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/10d30fc))
|
||||
- **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
|
||||
|
||||
- **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))
|
||||
|
||||
---
|
||||
|
||||
@@ -16,10 +106,11 @@
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
|
||||
| [a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad) | feat(frontend): group targets by bot, redesign backup settings | alexei.dolgolyov |
|
||||
| [bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928) | feat(server): add /status command handler for webhook providers | alexei.dolgolyov |
|
||||
| [87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c) | fix(frontend): stop event-log flicker on pagination | alexei.dolgolyov |
|
||||
| ---- | ------- | ------ |
|
||||
| [8651767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8651767) | feat: bridge_self bot commands — status, thresholds, reset, health | 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 |
|
||||
| [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>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -377,6 +377,46 @@ button:focus-visible, a:focus-visible {
|
||||
.stagger-children > * {
|
||||
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
|
||||
}
|
||||
|
||||
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
|
||||
Full-bleed rows that stretch to the main column width. Pair with .list-row
|
||||
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
|
||||
.list-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.list-row__identity {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
max-width: 28rem;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.list-row__identity { flex: 1 1 auto; }
|
||||
}
|
||||
.list-row__actions {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Secondary text under the name — visible only when meta-strip is hidden
|
||||
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
|
||||
.list-row__secondary {
|
||||
display: block;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.list-row__secondary { display: none; }
|
||||
}
|
||||
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
||||
@@ -465,3 +505,52 @@ button:focus-visible, a:focus-visible {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared toggle switch — used by provider config forms, tracking-config
|
||||
extraTrackingFields, and anywhere else we render a boolean field.
|
||||
Kept global so adding a new ConfigField type='toggle' caller doesn't
|
||||
need to copy the CSS into its scoped <style>. */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
left: 0.1875rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--color-foreground);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(1.125rem);
|
||||
background: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
+40
-8
@@ -2,8 +2,41 @@
|
||||
* API client with JWT auth for the Notify Bridge backend.
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Thrown when the API client decides to redirect the user to /login (after a
|
||||
* terminal 401). Caller-side `try/catch` blocks can branch on
|
||||
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
|
||||
* — the redirect itself is the user-visible signal.
|
||||
*/
|
||||
export class AuthRedirectError extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized — redirecting to login');
|
||||
this.name = 'AuthRedirectError';
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
|
||||
// the dashboard's parallel cache loads) should only schedule a single
|
||||
// `goto('/login')` instead of stacking N navigations.
|
||||
let _redirecting = false;
|
||||
|
||||
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
|
||||
function redirectToLogin(): void {
|
||||
if (_redirecting) return;
|
||||
_redirecting = true;
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
// SvelteKit's goto() with replaceState avoids leaving the failed page
|
||||
// in the back-stack (no "back-button to broken view" UX). We don't
|
||||
// reset `_redirecting` — the page about to unmount makes it moot.
|
||||
goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a caught error to a user-safe message. */
|
||||
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
@@ -129,11 +162,11 @@ export async function api<T = any>(
|
||||
}
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
redirectToLogin();
|
||||
// Tagged so the caller's catch can distinguish "we already showed
|
||||
// the user a redirect" from a real authorization failure they
|
||||
// should snackbar.
|
||||
throw new AuthRedirectError();
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
@@ -204,9 +237,8 @@ export async function fetchAuth(
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
redirectToLogin();
|
||||
throw new AuthRedirectError();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
}
|
||||
let { event, onclose }: Props = $props();
|
||||
|
||||
// Retain the last non-null event so the modal body stays populated
|
||||
// while the close transition plays after the parent clears `event`.
|
||||
let displayEvent = $state<EventLog | null>(null);
|
||||
$effect(() => {
|
||||
if (event) displayEvent = event;
|
||||
});
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
@@ -21,6 +28,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
|
||||
*
|
||||
* Used by the deferred-dispatch lifecycle banner to render
|
||||
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
|
||||
* integer that the user has to mentally divide. Keeps two units so
|
||||
* the magnitude reads correctly across hours-long quiet windows
|
||||
* without becoming noisy for short ones. */
|
||||
function humanDuration(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
|
||||
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMin = minutes % 60;
|
||||
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours ? `${days}d ${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
/** Render an absolute ISO timestamp as a future-relative string.
|
||||
*
|
||||
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
|
||||
* deferred-until banner shouldn't show a relative offset once the
|
||||
* window has already ended (a follow-up event_log row marks delivery).
|
||||
*/
|
||||
function timeFromNow(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const target = new Date(iso).getTime();
|
||||
const diff = Math.floor((target - Date.now()) / 1000);
|
||||
if (diff <= 0) return '';
|
||||
return humanDuration(diff);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
|
||||
if (!issuer) return '';
|
||||
if (issuer.username) return '@' + issuer.username;
|
||||
@@ -41,47 +86,130 @@
|
||||
goto(path);
|
||||
}
|
||||
|
||||
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||
const issuer = $derived(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||
const issuerText = $derived(issuerLabel(issuer));
|
||||
|
||||
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
|
||||
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
|
||||
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
|
||||
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
|
||||
|
||||
const detailsJson = $derived.by(() => {
|
||||
if (!event?.details) return '';
|
||||
if (!displayEvent?.details) return '';
|
||||
try {
|
||||
return JSON.stringify(event.details, null, 2);
|
||||
return JSON.stringify(displayEvent.details, null, 2);
|
||||
} catch {
|
||||
return String(event.details);
|
||||
return String(displayEvent.details);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if event}
|
||||
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if displayEvent}
|
||||
<div class="event-detail">
|
||||
<!-- Subject + verb -->
|
||||
<div class="hero-row">
|
||||
<MdiIcon name="mdiBell" size={18} />
|
||||
<div>
|
||||
<div class="hero-subject">{event.collection_name || event.event_type}</div>
|
||||
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
|
||||
<div class="hero-meta">
|
||||
<span class="event-type">{event.event_type}</span>
|
||||
<span class="event-type">{displayEvent.event_type}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDateTime(event.created_at)}</span>
|
||||
<span>{fmtDateTime(displayEvent.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispatch lifecycle (only when the event went through the
|
||||
quiet-hours defer path). Rendered ABOVE the provenance grid
|
||||
because timing of delivery is more interesting than the
|
||||
bot/tracker names when the event is held back. -->
|
||||
{#if displayEvent.details?.dispatch_status === 'deferred'}
|
||||
<section class="lifecycle lifecycle--deferred">
|
||||
<MdiIcon name="mdiPauseCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.heldUntil')}
|
||||
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
|
||||
{#if timeFromNow(displayEvent.details.deferred_until)}
|
||||
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
|
||||
<section class="lifecycle lifecycle--late">
|
||||
<MdiIcon name="mdiClockCheckOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
|
||||
{#if displayEvent.details.deferred_for_seconds != null}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.heldFor')}
|
||||
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiCloseCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
|
||||
{#if displayEvent.details.reason}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.reason')}:
|
||||
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
|
||||
{#if displayEvent.details.reason}
|
||||
<div class="lifecycle-detail">
|
||||
{t('events.lifecycle.reason')}:
|
||||
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{#if displayEvent.details.original_event_log_id}
|
||||
<div class="lifecycle-hint">
|
||||
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
|
||||
<section class="lifecycle lifecycle--dropped">
|
||||
<MdiIcon name="mdiVolumeOff" size={18} />
|
||||
<div class="lifecycle-body">
|
||||
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
|
||||
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Provenance grid -->
|
||||
<dl class="provenance">
|
||||
{#if event.bot_name}
|
||||
{#if displayEvent.bot_name}
|
||||
<dt>{t('events.bot')}</dt>
|
||||
<dd>{event.bot_name}</dd>
|
||||
<dd>{displayEvent.bot_name}</dd>
|
||||
{/if}
|
||||
{#if event.collection_id && isCommand}
|
||||
{#if displayEvent.collection_id && isCommand}
|
||||
<dt>{t('events.chat')}</dt>
|
||||
<dd class="font-mono">{event.collection_id}</dd>
|
||||
<dd class="font-mono">{displayEvent.collection_id}</dd>
|
||||
{/if}
|
||||
{#if issuerText}
|
||||
<dt>{t('events.issuer')}</dt>
|
||||
@@ -90,56 +218,64 @@
|
||||
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if event.command_tracker_name}
|
||||
{#if displayEvent.command_tracker_name}
|
||||
<dt>{t('events.commandTracker')}</dt>
|
||||
<dd>{event.command_tracker_name}</dd>
|
||||
<dd>{displayEvent.command_tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.tracker_name}
|
||||
{#if displayEvent.tracker_name}
|
||||
<dt>{t('events.tracker')}</dt>
|
||||
<dd>{event.tracker_name}</dd>
|
||||
<dd>{displayEvent.tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.action_name}
|
||||
{#if displayEvent.action_name}
|
||||
<dt>{t('events.action')}</dt>
|
||||
<dd>{event.action_name}</dd>
|
||||
<dd>{displayEvent.action_name}</dd>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
{#if displayEvent.provider_name}
|
||||
<dt>{t('events.provider')}</dt>
|
||||
<dd>{event.provider_name}</dd>
|
||||
<dd>{displayEvent.provider_name}</dd>
|
||||
{/if}
|
||||
{#if event.assets_count > 0}
|
||||
{#if displayEvent.assets_count > 0}
|
||||
<dt>{t('events.assetsCount')}</dt>
|
||||
<dd class="font-mono">{event.assets_count}</dd>
|
||||
<dd class="font-mono">{displayEvent.assets_count}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<!-- Action buttons — deep-link + highlight the related entity card -->
|
||||
<!-- Action buttons — deep-link + highlight the related entity card.
|
||||
IDs are snapshotted into local consts so the deferred onclick
|
||||
closures don't lose the narrowed type that the `{#if ...}` gate
|
||||
proves at template-render time. -->
|
||||
<div class="actions">
|
||||
{#if event.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
|
||||
{#if displayEvent.provider_id}
|
||||
{@const providerId = displayEvent.provider_id}
|
||||
<button type="button" onclick={() => openEntity('/providers', providerId)}>
|
||||
<MdiIcon name="mdiServer" size={14} />
|
||||
{t('events.openProvider')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.telegram_bot_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
|
||||
{#if displayEvent.telegram_bot_id && isCommand}
|
||||
{@const botId = displayEvent.telegram_bot_id}
|
||||
<button type="button" onclick={() => openEntity('/bots', botId)}>
|
||||
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||
{t('events.openBot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.command_tracker_id && isCommand}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
|
||||
{#if displayEvent.command_tracker_id && isCommand}
|
||||
{@const cmdTrackerId = displayEvent.command_tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
|
||||
{#if displayEvent.action_id && isAction}
|
||||
{@const actionId = displayEvent.action_id}
|
||||
<button type="button" onclick={() => openEntity('/actions', actionId)}>
|
||||
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||
{t('events.openAction')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCommand && !isAction && event.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
|
||||
{#if !isCommand && !isAction && displayEvent.tracker_id}
|
||||
{@const trackerId = displayEvent.tracker_id}
|
||||
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
|
||||
<MdiIcon name="mdiRadar" size={14} />
|
||||
{t('events.openTracker')}
|
||||
</button>
|
||||
@@ -251,4 +387,71 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Dispatch lifecycle banner — appears only when the event took the
|
||||
* quiet-hours defer path. The three colour variants mirror the dashboard
|
||||
* badge palette: primary glow for "held", success for "delivered late",
|
||||
* muted/dim for "dropped" / "failed" / "suppressed".
|
||||
*/
|
||||
.lifecycle {
|
||||
display: flex; align-items: flex-start; gap: 0.7rem;
|
||||
padding: 0.75rem 0.95rem;
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.lifecycle-body {
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.lifecycle-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.lifecycle-detail {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.lifecycle-detail b {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lifecycle-rel {
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.lifecycle-hint {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.lifecycle-reason {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 0.3rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
|
||||
word-break: break-all;
|
||||
}
|
||||
.lifecycle--deferred {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.lifecycle--deferred :global(svg) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.lifecycle--late {
|
||||
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
|
||||
}
|
||||
.lifecycle--late :global(svg) {
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
.lifecycle--dropped {
|
||||
opacity: 0.92;
|
||||
}
|
||||
.lifecycle--dropped :global(svg) {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
columns = 2,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
onChange,
|
||||
}: {
|
||||
items: GridItem[];
|
||||
value: string | number | null;
|
||||
@@ -24,6 +25,13 @@
|
||||
columns?: number;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Optional one-way change callback. Fired in addition to updating
|
||||
* `value` so callers that own state externally (e.g. a global store)
|
||||
* can avoid the read-modify-write feedback loop that `bind:value` plus
|
||||
* a sync `$effect` produces.
|
||||
*/
|
||||
onChange?: (value: string | number) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
@@ -63,6 +71,7 @@
|
||||
value = item.value;
|
||||
open = false;
|
||||
search = '';
|
||||
onChange?.(item.value);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
export type MetaTone = 'default' | 'mint' | 'sky' | 'coral' | 'citrus' | 'orchid' | 'lavender';
|
||||
|
||||
export interface MetaTile {
|
||||
icon?: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
hint?: string;
|
||||
tone?: MetaTone;
|
||||
mono?: boolean;
|
||||
href?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
copyValue?: string;
|
||||
}
|
||||
|
||||
let { tiles, align = 'start' }: {
|
||||
tiles: MetaTile[];
|
||||
align?: 'start' | 'end';
|
||||
} = $props();
|
||||
|
||||
function handleClick(e: MouseEvent, tile: MetaTile) {
|
||||
if (tile.onclick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
tile.onclick(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="meta-strip" style="justify-content: {align === 'end' ? 'flex-end' : 'flex-start'};">
|
||||
{#each tiles as tile, i (i)}
|
||||
{#if tile.href}
|
||||
<a
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
href={tile.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</a>
|
||||
{:else if tile.onclick}
|
||||
<button
|
||||
type="button"
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
onclick={(e: MouseEvent) => handleClick(e, tile)}
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="meta-tile meta-tone-{tile.tone || 'default'}"
|
||||
class:meta-tile--mono={tile.mono}
|
||||
title={tile.hint}
|
||||
>
|
||||
{#if tile.icon}
|
||||
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
|
||||
{/if}
|
||||
<span class="meta-tile__text">
|
||||
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
|
||||
<span class="meta-tile__label">{tile.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.meta-strip {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
|
||||
padding: 2px 18px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.meta-strip {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-tile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 22rem;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.meta-tile__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: currentColor;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-tile__text {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-tile__value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta-tile__label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-tile--mono .meta-tile__label,
|
||||
.meta-tile--mono .meta-tile__value {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-tile--interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.meta-tile--interactive:hover {
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Tone variants — applied to the dot/icon and accent border on hover */
|
||||
.meta-tone-mint { box-shadow: inset 2px 0 0 var(--color-mint); }
|
||||
.meta-tone-sky { box-shadow: inset 2px 0 0 var(--color-sky); }
|
||||
.meta-tone-coral { box-shadow: inset 2px 0 0 var(--color-coral); }
|
||||
.meta-tone-citrus { box-shadow: inset 2px 0 0 var(--color-citrus); }
|
||||
.meta-tone-orchid { box-shadow: inset 2px 0 0 var(--color-orchid); }
|
||||
.meta-tone-lavender { box-shadow: inset 2px 0 0 var(--color-primary); }
|
||||
|
||||
.meta-tone-mint .meta-tile__icon { color: var(--color-mint); }
|
||||
.meta-tone-sky .meta-tile__icon { color: var(--color-sky); }
|
||||
.meta-tone-coral .meta-tile__icon { color: var(--color-coral); }
|
||||
.meta-tone-citrus .meta-tile__icon { color: var(--color-citrus); }
|
||||
.meta-tone-orchid .meta-tile__icon { color: var(--color-orchid); }
|
||||
.meta-tone-lavender .meta-tile__icon { color: var(--color-primary); }
|
||||
</style>
|
||||
@@ -11,14 +11,22 @@
|
||||
}>();
|
||||
|
||||
let visible = $state(false);
|
||||
let mounted = $state(false);
|
||||
let panelEl = $state<HTMLDivElement | undefined>();
|
||||
let previouslyFocused: HTMLElement | null = null;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const TRANSITION_MS = 250;
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
mounted = true;
|
||||
requestAnimationFrame(() => {
|
||||
visible = true;
|
||||
// Focus first focusable element inside the modal
|
||||
@@ -29,13 +37,18 @@
|
||||
focusable?.focus();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
} else if (mounted) {
|
||||
visible = false;
|
||||
// Restore focus to the previously focused element
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
closeTimer = setTimeout(() => {
|
||||
mounted = false;
|
||||
closeTimer = null;
|
||||
}, TRANSITION_MS);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,7 +86,7 @@
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
{#if mounted}
|
||||
<div use:portal class="modal-portal-root">
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Free-text chip input. Bind a string[] of values; commit a new chip on
|
||||
* Enter, comma, or blur. Backspace on empty input deletes the last chip
|
||||
* for parity with native chip-input UX.
|
||||
*
|
||||
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
|
||||
* free-text filter keys like Home Assistant's entity_glob and
|
||||
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
|
||||
* picker dropdown sourced from an enumerable list.
|
||||
*/
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
values: string[];
|
||||
onchange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
icon?: string;
|
||||
/** Strip / reject anything matching this regex on each entry. */
|
||||
sanitize?: (raw: string) => string | null;
|
||||
}
|
||||
|
||||
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
|
||||
|
||||
let draft = $state('');
|
||||
|
||||
function addRaw(raw: string): void {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
|
||||
if (!cleaned) return;
|
||||
if (values.includes(cleaned)) return;
|
||||
onchange([...values, cleaned]);
|
||||
}
|
||||
|
||||
function commitDraft(): void {
|
||||
if (!draft.trim()) return;
|
||||
// Allow comma-separated paste — split on commas and add each.
|
||||
for (const piece of draft.split(',')) {
|
||||
addRaw(piece);
|
||||
}
|
||||
draft = '';
|
||||
}
|
||||
|
||||
function removeAt(index: number): void {
|
||||
onchange(values.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||||
e.preventDefault();
|
||||
removeAt(values.length - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tag-input">
|
||||
{#each values as value, i (`${i}-${value}`)}
|
||||
<span class="tag-chip">
|
||||
{#if icon}<MdiIcon name={icon} size={12} />{/if}
|
||||
<span class="tag-text">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
class="tag-remove"
|
||||
onclick={() => removeAt(i)}
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft}
|
||||
onkeydown={onKey}
|
||||
onblur={commitDraft}
|
||||
placeholder={values.length === 0 ? placeholder : ''}
|
||||
class="tag-draft"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-background);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tag-input:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: var(--color-muted);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-draft {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tag-draft::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -120,6 +120,16 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
||||
];
|
||||
|
||||
// --- Provider stats scope (dashboard "On watch" deck) ---
|
||||
//
|
||||
// Toggles whether the provider deck stats reflect only the events visible
|
||||
// on the current page or aggregate across all events matching the filters.
|
||||
|
||||
export const providerStatsModeItems = (): GridItem[] => [
|
||||
{ value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') },
|
||||
{ value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') },
|
||||
];
|
||||
|
||||
// --- Auto-refresh interval (dashboard events list) ---
|
||||
//
|
||||
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||
@@ -175,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
||||
...allDescriptors().map(descriptorToGridItem),
|
||||
];
|
||||
|
||||
/** Provider types the user is allowed to create from the "new provider" wizard.
|
||||
*
|
||||
* Excludes ``bridge_self`` because it's auto-created exactly once per user
|
||||
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
|
||||
* wizard would either duplicate the row or surface a confusing 409.
|
||||
*/
|
||||
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
|
||||
allDescriptors()
|
||||
.filter((d) => d.type !== 'bridge_self')
|
||||
.map((d) => d.type);
|
||||
|
||||
/** Provider type selector (no "All" option). */
|
||||
export const providerTypeItems = (): GridItem[] =>
|
||||
allDescriptors().map(descriptorToGridItem);
|
||||
allDescriptors()
|
||||
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
|
||||
.map(descriptorToGridItem);
|
||||
|
||||
@@ -124,6 +124,15 @@
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"loadingEvents": "Loading events...",
|
||||
"heldUntil": "held until",
|
||||
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
|
||||
"deliveredLate": "delivered late",
|
||||
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
|
||||
"deferredThenDropped": "dropped after defer",
|
||||
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
|
||||
"deferredThenFailed": "failed after defer",
|
||||
"suppressedQuietHours": "suppressed (quiet hours)",
|
||||
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
|
||||
"asset": "asset",
|
||||
"assets": "assets",
|
||||
"eventActivity": "Event Activity",
|
||||
@@ -148,6 +157,9 @@
|
||||
"eventsLabel": "events",
|
||||
"onWatchTitle": "On",
|
||||
"onWatchEmphasis": "watch",
|
||||
"statsModeTitle": "Provider deck stats scope",
|
||||
"statsModePage": "Page",
|
||||
"statsModeAll": "All",
|
||||
"noProviders": "No providers yet.",
|
||||
"addProvider": "Add provider",
|
||||
"addProviderHint": "Connect a service to start tracking",
|
||||
@@ -179,7 +191,21 @@
|
||||
"openCommandTracker": "Open command tracker",
|
||||
"openAction": "Open action",
|
||||
"openTracker": "Open tracker",
|
||||
"rawDetails": "Raw details"
|
||||
"rawDetails": "Raw details",
|
||||
"lifecycle": {
|
||||
"heldTitle": "Held by quiet hours",
|
||||
"heldUntil": "Will dispatch at",
|
||||
"heldFor": "Held for",
|
||||
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
|
||||
"inPrefix": "in",
|
||||
"deliveredLateTitle": "Delivered after quiet hours",
|
||||
"originalEvent": "Original event",
|
||||
"droppedTitle": "Dropped after defer",
|
||||
"failedTitle": "Failed after defer",
|
||||
"reason": "Reason",
|
||||
"suppressedTitle": "Suppressed by quiet hours",
|
||||
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Service",
|
||||
@@ -209,6 +235,20 @@
|
||||
"typeNut": "NUT (UPS)",
|
||||
"typeGooglePhotos": "Google Photos",
|
||||
"typeWebhook": "Generic Webhook",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"typeBridgeSelf": "Bridge Self-Monitoring",
|
||||
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
|
||||
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
|
||||
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
|
||||
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
|
||||
"bridgeSelfTargetThreshold": "Target send failure threshold",
|
||||
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
|
||||
"haAccessToken": "Long-Lived Access Token",
|
||||
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
|
||||
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
|
||||
"haAccessTokenRequired": "Home Assistant access token is required.",
|
||||
"haVerifyTls": "Verify TLS certificate",
|
||||
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
@@ -294,6 +334,13 @@
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
"selectUpsDevices": "Select UPS devices...",
|
||||
"entities": "Entities",
|
||||
"selectEntities": "Select entities...",
|
||||
"entities_count": "entity(ies)",
|
||||
"haEntityGlob": "Entity glob filter",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Domain allowlist",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Event Types",
|
||||
"notificationTargets": "Notification Targets",
|
||||
"scanInterval": "Scan Interval (seconds)",
|
||||
@@ -474,6 +521,7 @@
|
||||
"countLabel": "users",
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"you": "you",
|
||||
"addUser": "Add User",
|
||||
"cancel": "Cancel",
|
||||
"username": "Username",
|
||||
@@ -617,6 +665,14 @@
|
||||
"upsOverload": "UPS overloaded",
|
||||
"scheduledMessage": "Scheduled message",
|
||||
"webhookReceived": "Webhook received",
|
||||
"haStateChanged": "Entity state changed",
|
||||
"haAutomationTriggered": "Automation triggered",
|
||||
"haServiceCalled": "Service called",
|
||||
"haEventFired": "Other HA event (catch-all)",
|
||||
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
|
||||
"bridgeSelfPollFailures": "Tracker poll failures",
|
||||
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
|
||||
"bridgeSelfTargetFailures": "Target send failures",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
@@ -870,7 +926,58 @@
|
||||
"changedOne": "1 setting changed",
|
||||
"changedMany": "{n} settings changed",
|
||||
"discard": "Discard",
|
||||
"saveChanges": "Save changes"
|
||||
"saveChanges": "Save changes",
|
||||
"release": {
|
||||
"eyebrow": "Releases",
|
||||
"headline": "Stay current with upstream",
|
||||
"provider": "Provider",
|
||||
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
|
||||
"comingSoon": "Coming soon",
|
||||
"disabled": "Disabled",
|
||||
"repository": "Repository",
|
||||
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
|
||||
"options": "Options",
|
||||
"includePrereleases": "Include pre-releases",
|
||||
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
|
||||
"interval": "Check interval",
|
||||
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
|
||||
"intervalRange": "1–168 hrs",
|
||||
"hoursUnit": "hrs",
|
||||
"testConnection": "Test connection",
|
||||
"checkNow": "Check now",
|
||||
"checkDone": "Release check complete",
|
||||
"checkFailed": "Release check failed",
|
||||
"testOk": "Provider reachable",
|
||||
"testFailed": "Provider unreachable",
|
||||
"testFound": "Provider returned",
|
||||
"viewRelease": "View v{v} release",
|
||||
"statusUpToDate": "You're up to date",
|
||||
"statusUpdate": "Update available",
|
||||
"statusDisabled": "Release checks disabled",
|
||||
"statusError": "Last check failed",
|
||||
"statusUnknown": "Not checked yet",
|
||||
"heroAvailable": "available",
|
||||
"updateAvailableTooltip": "v{v} available — open Settings",
|
||||
"lastChecked": "Last checked",
|
||||
"never": "never",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"daysAgo": "{n} d ago",
|
||||
"error": {
|
||||
"disabled": "Release checks are disabled",
|
||||
"misconfigured": "Provider not fully configured",
|
||||
"provider_changed": "Provider changed — awaiting next check",
|
||||
"no_release_found": "No matching release found upstream",
|
||||
"network_error": "Upstream unreachable",
|
||||
"http_error": "Upstream returned an error",
|
||||
"parse_error": "Upstream response could not be parsed",
|
||||
"unsafe_url": "URL rejected by safety check",
|
||||
"not_implemented": "Provider not implemented yet",
|
||||
"unknown_error": "Unknown error",
|
||||
"error": "Last check failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||
@@ -1020,6 +1127,18 @@
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Bridge status",
|
||||
"statusDesc": "Show current bridge health counters",
|
||||
"thresholds": "Bridge thresholds",
|
||||
"thresholdsDesc": "Show configured alert thresholds",
|
||||
"reset": "Reset counter",
|
||||
"resetDesc": "Manually reset a failure counter",
|
||||
"health": "Bridge health",
|
||||
"healthDesc": "Terse one-line health summary"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
@@ -1031,6 +1150,8 @@
|
||||
"noMatches": "No timezones match"
|
||||
},
|
||||
"locales": {
|
||||
"label": "language",
|
||||
"labelPlural": "languages",
|
||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||
"add": "Add language",
|
||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||
@@ -1100,6 +1221,7 @@
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"auto": "Auto",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
@@ -1241,6 +1363,8 @@
|
||||
"refresh30s": "Refresh every 30 seconds",
|
||||
"refresh60s": "Refresh every minute",
|
||||
"refresh5m": "Refresh every 5 minutes",
|
||||
"statsModePage": "Count only events on the current page",
|
||||
"statsModeAll": "Count all events matching the current filters",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
@@ -1263,7 +1387,9 @@
|
||||
"providerScheduler": "Time-based scheduled messages",
|
||||
"providerNut": "Network UPS monitoring",
|
||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
||||
"providerWebhook": "Receive events via HTTP POST"
|
||||
"providerWebhook": "Receive events via HTTP POST",
|
||||
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
|
||||
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Recent Payloads",
|
||||
|
||||
@@ -124,6 +124,15 @@
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
"loadingEvents": "Загрузка событий...",
|
||||
"heldUntil": "ожидает до",
|
||||
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
|
||||
"deliveredLate": "доставлено позже",
|
||||
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
|
||||
"deferredThenDropped": "отброшено после задержки",
|
||||
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
|
||||
"deferredThenFailed": "ошибка после задержки",
|
||||
"suppressedQuietHours": "подавлено (тихие часы)",
|
||||
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
|
||||
"asset": "файл",
|
||||
"assets": "файлов",
|
||||
"eventActivity": "Активность событий",
|
||||
@@ -148,6 +157,9 @@
|
||||
"eventsLabel": "событий",
|
||||
"onWatchTitle": "На",
|
||||
"onWatchEmphasis": "слежении",
|
||||
"statsModeTitle": "Область статистики провайдеров",
|
||||
"statsModePage": "Страница",
|
||||
"statsModeAll": "Все",
|
||||
"noProviders": "Пока нет провайдеров.",
|
||||
"addProvider": "Добавить",
|
||||
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
||||
@@ -179,7 +191,21 @@
|
||||
"openCommandTracker": "Открыть командный трекер",
|
||||
"openAction": "Открыть действие",
|
||||
"openTracker": "Открыть трекер",
|
||||
"rawDetails": "Сырые данные"
|
||||
"rawDetails": "Сырые данные",
|
||||
"lifecycle": {
|
||||
"heldTitle": "Задержано тихими часами",
|
||||
"heldUntil": "Будет отправлено в",
|
||||
"heldFor": "Задержано на",
|
||||
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
|
||||
"inPrefix": "через",
|
||||
"deliveredLateTitle": "Доставлено после тихих часов",
|
||||
"originalEvent": "Исходное событие",
|
||||
"droppedTitle": "Отброшено после задержки",
|
||||
"failedTitle": "Ошибка после задержки",
|
||||
"reason": "Причина",
|
||||
"suppressedTitle": "Подавлено тихими часами",
|
||||
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Сервисные",
|
||||
@@ -209,6 +235,20 @@
|
||||
"typeNut": "NUT (ИБП)",
|
||||
"typeGooglePhotos": "Google Фото",
|
||||
"typeWebhook": "Универсальный вебхук",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"typeBridgeSelf": "Самомониторинг моста",
|
||||
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
|
||||
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
|
||||
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
|
||||
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
|
||||
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
|
||||
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
|
||||
"haAccessToken": "Долгоживущий токен доступа",
|
||||
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
|
||||
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
|
||||
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
|
||||
"haVerifyTls": "Проверять TLS-сертификат",
|
||||
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
@@ -294,6 +334,13 @@
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
"selectUpsDevices": "Выберите ИБП...",
|
||||
"entities": "Сущности",
|
||||
"selectEntities": "Выберите сущности...",
|
||||
"entities_count": "сущность(ей)",
|
||||
"haEntityGlob": "Фильтр по entity (glob)",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Разрешённые домены",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Типы событий",
|
||||
"notificationTargets": "Получатели уведомлений",
|
||||
"scanInterval": "Интервал проверки (секунды)",
|
||||
@@ -474,6 +521,7 @@
|
||||
"countLabel": "пользователей",
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"you": "вы",
|
||||
"addUser": "Добавить пользователя",
|
||||
"cancel": "Отмена",
|
||||
"username": "Имя пользователя",
|
||||
@@ -617,6 +665,14 @@
|
||||
"upsOverload": "Перегрузка ИБП",
|
||||
"scheduledMessage": "Запланированное сообщение",
|
||||
"webhookReceived": "Вебхук получен",
|
||||
"haStateChanged": "Состояние сущности изменилось",
|
||||
"haAutomationTriggered": "Сработала автоматизация",
|
||||
"haServiceCalled": "Вызвана служба",
|
||||
"haEventFired": "Прочее событие HA (catch-all)",
|
||||
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||
"bridgeSelfPollFailures": "Сбои опроса трекера",
|
||||
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
|
||||
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
@@ -870,7 +926,58 @@
|
||||
"changedOne": "Изменена 1 настройка",
|
||||
"changedMany": "Изменено настроек: {n}",
|
||||
"discard": "Отменить",
|
||||
"saveChanges": "Сохранить"
|
||||
"saveChanges": "Сохранить",
|
||||
"release": {
|
||||
"eyebrow": "Релизы",
|
||||
"headline": "Следите за обновлениями",
|
||||
"provider": "Источник",
|
||||
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
|
||||
"comingSoon": "Скоро",
|
||||
"disabled": "Отключено",
|
||||
"repository": "Репозиторий",
|
||||
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
|
||||
"options": "Опции",
|
||||
"includePrereleases": "Учитывать пре-релизы",
|
||||
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
|
||||
"interval": "Интервал проверки",
|
||||
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
|
||||
"intervalRange": "1–168 ч",
|
||||
"hoursUnit": "ч",
|
||||
"testConnection": "Проверить связь",
|
||||
"checkNow": "Проверить сейчас",
|
||||
"checkDone": "Проверка релизов завершена",
|
||||
"checkFailed": "Не удалось проверить релизы",
|
||||
"testOk": "Источник доступен",
|
||||
"testFailed": "Источник недоступен",
|
||||
"testFound": "Найдена версия",
|
||||
"viewRelease": "Открыть релиз v{v}",
|
||||
"statusUpToDate": "Актуальная версия",
|
||||
"statusUpdate": "Доступно обновление",
|
||||
"statusDisabled": "Проверка релизов отключена",
|
||||
"statusError": "Ошибка последней проверки",
|
||||
"statusUnknown": "Ещё не проверялось",
|
||||
"heroAvailable": "доступна",
|
||||
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
|
||||
"lastChecked": "Последняя проверка",
|
||||
"never": "никогда",
|
||||
"justNow": "только что",
|
||||
"minutesAgo": "{n} мин назад",
|
||||
"hoursAgo": "{n} ч назад",
|
||||
"daysAgo": "{n} д назад",
|
||||
"error": {
|
||||
"disabled": "Проверка релизов отключена",
|
||||
"misconfigured": "Источник настроен не полностью",
|
||||
"provider_changed": "Источник изменён — ожидание следующей проверки",
|
||||
"no_release_found": "Подходящий релиз на источнике не найден",
|
||||
"network_error": "Источник недоступен",
|
||||
"http_error": "Источник вернул ошибку",
|
||||
"parse_error": "Не удалось разобрать ответ источника",
|
||||
"unsafe_url": "URL отклонён проверкой безопасности",
|
||||
"not_implemented": "Источник пока не реализован",
|
||||
"unknown_error": "Неизвестная ошибка",
|
||||
"error": "Ошибка последней проверки"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
@@ -1020,6 +1127,18 @@
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Состояние моста",
|
||||
"statusDesc": "Показать счётчики состояния моста",
|
||||
"thresholds": "Пороги моста",
|
||||
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||
"reset": "Сбросить счётчик",
|
||||
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||
"health": "Здоровье моста",
|
||||
"healthDesc": "Краткая однострочная сводка состояния"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
@@ -1031,6 +1150,8 @@
|
||||
"noMatches": "Нет совпадений"
|
||||
},
|
||||
"locales": {
|
||||
"label": "язык",
|
||||
"labelPlural": "языков",
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
@@ -1100,6 +1221,7 @@
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"auto": "Авто",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
@@ -1241,6 +1363,8 @@
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"statsModePage": "Учитывать только события на текущей странице",
|
||||
"statsModeAll": "Учитывать все события под текущими фильтрами",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
@@ -1263,7 +1387,9 @@
|
||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||
"providerNut": "Мониторинг ИБП через NUT",
|
||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||
"providerWebhook": "Приём событий через HTTP POST"
|
||||
"providerWebhook": "Приём событий через HTTP POST",
|
||||
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
|
||||
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Последние запросы",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
/**
|
||||
* Bridge self-monitoring provider descriptor.
|
||||
*
|
||||
* The bridge_self provider has no remote URL and no credentials. The only
|
||||
* configuration surface is the three thresholds below, used by the server
|
||||
* to decide when an internal failure deserves a notification.
|
||||
*
|
||||
* Exactly one bridge_self provider exists per user, auto-seeded on user
|
||||
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
|
||||
*/
|
||||
export const bridgeSelfDescriptor: ProviderDescriptor = {
|
||||
type: 'bridge_self',
|
||||
defaultName: 'Bridge Self-Monitoring',
|
||||
icon: 'mdiAlertCircleOutline',
|
||||
hasUrl: false,
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'poll_failure_threshold',
|
||||
configKey: 'poll_failure_threshold',
|
||||
label: 'providers.bridgeSelfPollThreshold',
|
||||
type: 'number',
|
||||
optional: true,
|
||||
min: 1,
|
||||
defaultValue: 3,
|
||||
hint: 'providers.bridgeSelfPollThresholdHint',
|
||||
},
|
||||
{
|
||||
key: 'deferred_backlog_threshold',
|
||||
configKey: 'deferred_backlog_threshold',
|
||||
label: 'providers.bridgeSelfDeferredThreshold',
|
||||
type: 'number',
|
||||
optional: true,
|
||||
min: 1,
|
||||
defaultValue: 100,
|
||||
hint: 'providers.bridgeSelfDeferredThresholdHint',
|
||||
},
|
||||
{
|
||||
key: 'target_failure_threshold',
|
||||
configKey: 'target_failure_threshold',
|
||||
label: 'providers.bridgeSelfTargetThreshold',
|
||||
type: 'number',
|
||||
optional: true,
|
||||
min: 1,
|
||||
defaultValue: 5,
|
||||
hint: 'providers.bridgeSelfTargetThresholdHint',
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form) {
|
||||
const toInt = (raw: unknown, fallback: number): number => {
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
||||
};
|
||||
return {
|
||||
config: {
|
||||
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
|
||||
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
|
||||
target_failure_threshold: toInt(form.target_failure_threshold, 5),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
const toInt = (raw: unknown, fallback: number): number => {
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
||||
};
|
||||
return (
|
||||
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
|
||||
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
|
||||
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
|
||||
);
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{
|
||||
key: 'track_bridge_self_poll_failures',
|
||||
label: 'trackingConfig.bridgeSelfPollFailures',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'track_bridge_self_deferred_backlog',
|
||||
label: 'trackingConfig.bridgeSelfDeferredBacklog',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'track_bridge_self_target_failures',
|
||||
label: 'trackingConfig.bridgeSelfTargetFailures',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: null,
|
||||
webhookBased: false,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const homeAssistantDescriptor: ProviderDescriptor = {
|
||||
type: 'home_assistant',
|
||||
defaultName: 'Home Assistant',
|
||||
icon: 'mdiHomeAssistant',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: 'http://homeassistant.local:8123',
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'access_token', configKey: 'access_token',
|
||||
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
|
||||
},
|
||||
{
|
||||
key: 'verify_tls', configKey: 'verify_tls',
|
||||
label: 'providers.haVerifyTls',
|
||||
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, unknown> = { url: form.url };
|
||||
if (form.access_token) config.access_token = form.access_token;
|
||||
// Coerce truthy/falsy form values to a real boolean. The toggle
|
||||
// control binds to `checked`, so this is normally already a bool,
|
||||
// but legacy form state may carry the string defaults.
|
||||
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
|
||||
if (!editing && !form.access_token) {
|
||||
return { config, error: 'providers.haAccessTokenRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
const existingVerify = existing.verify_tls !== false;
|
||||
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
|
||||
return (
|
||||
form.url !== (existing.url || '') ||
|
||||
!!form.access_token ||
|
||||
existingVerify !== formVerify
|
||||
);
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
|
||||
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
|
||||
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
|
||||
{
|
||||
key: 'track_ha_event_fired',
|
||||
label: 'trackingConfig.haEventFired',
|
||||
default: false,
|
||||
hint: 'trackingConfig.haEventFiredHint',
|
||||
},
|
||||
],
|
||||
|
||||
// entity_glob / domain_allowlist tag-style filters. Stored on the
|
||||
// tracker's `filters` JSON column (not the flat form root) — the
|
||||
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
|
||||
// than a picker, and `filterKey` routes the value into
|
||||
// `tracker.filters[filterKey]` at save time.
|
||||
userFilters: [
|
||||
{
|
||||
key: 'entity_glob',
|
||||
filterKey: 'entity_glob',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haEntityGlob',
|
||||
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
|
||||
icon: 'mdiAsterisk',
|
||||
},
|
||||
{
|
||||
key: 'domain_allowlist',
|
||||
filterKey: 'domain_allowlist',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haDomainAllowlist',
|
||||
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
|
||||
icon: 'mdiTagOutline',
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.entities',
|
||||
icon: 'mdiViewList',
|
||||
placeholder: 'notificationTracker.selectEntities',
|
||||
countLabel: 'notificationTracker.entities_count',
|
||||
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
|
||||
const parts: string[] = [];
|
||||
if (col.domain) parts.push(col.domain);
|
||||
if (col.state) parts.push(col.state);
|
||||
if (parts.length === 0) return col.entity_id || col.id || '';
|
||||
return parts.join(' · ');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -113,6 +113,17 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
||||
},
|
||||
|
||||
// Periodic summaries / scheduled picks / memories / quiet hours all live on
|
||||
// the linked tracking & template configs — surface that connection on the
|
||||
// tracker form so users don't need to read docs to find them.
|
||||
featureDiscoveryHint: {
|
||||
messageKey: 'notificationTracker.featureDiscovery',
|
||||
ctas: [
|
||||
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
|
||||
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
|
||||
],
|
||||
},
|
||||
|
||||
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
||||
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
||||
if (newIds.length === 0) return { proceed: true };
|
||||
|
||||
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
|
||||
import { nutDescriptor } from './nut';
|
||||
import { googlePhotosDescriptor } from './google-photos';
|
||||
import { webhookDescriptor } from './webhook';
|
||||
import { homeAssistantDescriptor } from './home-assistant';
|
||||
import { bridgeSelfDescriptor } from './bridge-self';
|
||||
|
||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['immich', immichDescriptor],
|
||||
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['nut', nutDescriptor],
|
||||
['google_photos', googlePhotosDescriptor],
|
||||
['webhook', webhookDescriptor],
|
||||
['home_assistant', homeAssistantDescriptor],
|
||||
['bridge_self', bridgeSelfDescriptor],
|
||||
]);
|
||||
|
||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ConfigField {
|
||||
configKey?: string;
|
||||
/** i18n key for the field label. */
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'grid-select';
|
||||
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
@@ -123,17 +123,30 @@ export interface CollectionMeta {
|
||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a filter that picks user identities from the provider's known
|
||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||
* `tracker.filters[key]`.
|
||||
* Declares a filter rendered on the tracker form. Two input modes:
|
||||
*
|
||||
* * ``picker`` (default) — populated from the provider's ``/users``
|
||||
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
|
||||
* allowlists / blocklists where the valid values are known.
|
||||
* * ``tags`` — free-text chip input. Used for glob patterns and other
|
||||
* filter values that aren't enumerable in advance.
|
||||
*
|
||||
* Either way the picked values are stored as ``string[]`` under
|
||||
* ``tracker.filters[filterKey ?? key]``.
|
||||
*/
|
||||
export interface UserFilterMeta {
|
||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||
/** Form field key — used internally for binding. */
|
||||
key: string;
|
||||
/** i18n key for the label rendered above the picker. */
|
||||
/**
|
||||
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
|
||||
* omitted (backward compat with the original sender allowlist usage).
|
||||
*/
|
||||
filterKey?: string;
|
||||
/** ``picker`` (default) or ``tags`` for free-text chip input. */
|
||||
inputMode?: 'picker' | 'tags';
|
||||
/** i18n key for the label rendered above the input. */
|
||||
label: string;
|
||||
/** i18n key for the picker placeholder. */
|
||||
/** i18n key for the placeholder (picker dropdown or chip input). */
|
||||
placeholder: string;
|
||||
/** MDI icon shown on chips and dropdown rows. */
|
||||
icon: string;
|
||||
@@ -183,6 +196,22 @@ export interface ProviderDescriptor {
|
||||
/** Whether this provider stores incoming payload history for debugging. */
|
||||
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 ──
|
||||
/**
|
||||
* Called after collection selection changes (before save).
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
CommandTemplateConfig,
|
||||
CommandTracker,
|
||||
Action,
|
||||
ReleaseStatus,
|
||||
} from '$lib/types';
|
||||
|
||||
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
|
||||
@@ -140,6 +141,46 @@ export const externalUrlCache = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/** Upstream release status — drives the sidebar badge and Settings cassette. */
|
||||
export const releaseStatusCache = (() => {
|
||||
let data = $state<ReleaseStatus | null>(null);
|
||||
let fetchedAt = $state(0);
|
||||
let inflight: Promise<ReleaseStatus | null> | null = null;
|
||||
// 5 min TTL — fresh enough that "Check now" feels instant on revisit,
|
||||
// long enough that route changes don't hammer the endpoint.
|
||||
const TTL = 300_000;
|
||||
return {
|
||||
get value() { return data; },
|
||||
invalidate() { fetchedAt = 0; },
|
||||
clear() {
|
||||
data = null;
|
||||
fetchedAt = 0;
|
||||
inflight = null;
|
||||
},
|
||||
set(next: ReleaseStatus | null) {
|
||||
data = next;
|
||||
fetchedAt = Date.now();
|
||||
},
|
||||
async fetch(force = false): Promise<ReleaseStatus | null> {
|
||||
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
data = await api<ReleaseStatus>('/settings/release');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
} catch {
|
||||
// Swallow — the badge falls back to its default "no status" state.
|
||||
return data;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
/** Supported template locales — fetched from app settings. */
|
||||
export const supportedLocalesCache = (() => {
|
||||
let data = $state<string[]>(['en', 'ru']);
|
||||
@@ -192,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Invalidate all entity caches. Useful on logout.
|
||||
*
|
||||
* Singleton state caches (release status, external URL, supported locales)
|
||||
* live outside `allCaches` because their shape differs from entity caches —
|
||||
* we clear them explicitly so a returning user as a different role can't
|
||||
* briefly see the previous user's cached payload.
|
||||
*/
|
||||
export function clearAllCaches(): void {
|
||||
Object.values(allCaches).forEach(c => c.clear());
|
||||
releaseStatusCache.clear();
|
||||
}
|
||||
|
||||
@@ -16,8 +16,19 @@ const DEFAULT_TTL_MS = 30_000; // 30 seconds
|
||||
export interface EntityCache<T extends { id: number }> {
|
||||
/** Reactive list of cached entities. */
|
||||
readonly items: T[];
|
||||
/** True only during the very first fetch (no cached data yet). */
|
||||
/**
|
||||
* True only during the very first fetch — when there is no cached data
|
||||
* to show yet. Background re-fetches keep `loading` false so consumers
|
||||
* keep rendering the previous list and don't flash a spinner; observe
|
||||
* `refreshing` instead if a subtle indicator is needed.
|
||||
*/
|
||||
readonly loading: boolean;
|
||||
/**
|
||||
* True during any non-first fetch (cached items already populated).
|
||||
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
|
||||
* shimmer/disabled state" (refreshing) without sharing one flag.
|
||||
*/
|
||||
readonly refreshing: boolean;
|
||||
/** Timestamp of last successful fetch. */
|
||||
readonly fetchedAt: number;
|
||||
/** Fetch entities — returns cached data if fresh, else hits network. */
|
||||
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
|
||||
): EntityCache<T> {
|
||||
let _items = $state<T[]>([]);
|
||||
let _loading = $state(false);
|
||||
let _refreshing = $state(false);
|
||||
let _fetchedAt = $state(0);
|
||||
|
||||
function isFresh(): boolean {
|
||||
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
|
||||
const existing = inflightRequests.get(endpoint);
|
||||
if (existing) return existing;
|
||||
|
||||
// First-load vs background-refresh state. We split these so consumers
|
||||
// can keep the previous list visible during a re-fetch (refreshing)
|
||||
// instead of flashing a spinner placeholder (loading).
|
||||
const isFirstLoad = _fetchedAt === 0;
|
||||
if (isFirstLoad) _loading = true;
|
||||
else _refreshing = true;
|
||||
|
||||
const request = api<T[]>(endpoint)
|
||||
.then((data) => {
|
||||
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
|
||||
})
|
||||
.finally(() => {
|
||||
_loading = false;
|
||||
_refreshing = false;
|
||||
inflightRequests.delete(endpoint);
|
||||
});
|
||||
|
||||
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
|
||||
return {
|
||||
get items() { return _items; },
|
||||
get loading() { return _loading; },
|
||||
get refreshing() { return _refreshing; },
|
||||
get fetchedAt() { return _fetchedAt; },
|
||||
fetch,
|
||||
invalidate,
|
||||
|
||||
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
|
||||
loadFromStorage();
|
||||
|
||||
export const globalProviderFilter = {
|
||||
get id() {
|
||||
// If providers are loaded and the stored ID doesn't match any, auto-clear
|
||||
if (_providerId != null && providersCache.items.length > 0 &&
|
||||
!providersCache.items.some(p => p.id === _providerId)) {
|
||||
globalProviderFilter.clear();
|
||||
return null;
|
||||
}
|
||||
return _providerId;
|
||||
},
|
||||
/**
|
||||
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
|
||||
* reconciliation against `providersCache` is the responsibility of a
|
||||
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
|
||||
* because writing during read inside a `$state`-derived getter triggers
|
||||
* Svelte 5's `state_unsafe_mutation` warning.
|
||||
*/
|
||||
get id() { return _providerId; },
|
||||
get initialized() { return _initialized; },
|
||||
|
||||
set(id: number | null) {
|
||||
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
|
||||
this.set(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the stored provider ID if it no longer matches any item in the
|
||||
* providers cache. Safe to call from a `$effect` after the cache has been
|
||||
* fetched. Returns true when reconciliation actually changed state, so the
|
||||
* caller can short-circuit follow-up work.
|
||||
*/
|
||||
reconcileWithCache(): boolean {
|
||||
if (_providerId != null && providersCache.items.length > 0 &&
|
||||
!providersCache.items.some(p => p.id === _providerId)) {
|
||||
this.clear();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/** The currently selected provider object (reactive). */
|
||||
get provider() {
|
||||
const id = this.id; // triggers stale-ID auto-clear
|
||||
const id = _providerId;
|
||||
if (id == null) return null;
|
||||
return providersCache.items.find(p => p.id === id) ?? null;
|
||||
},
|
||||
|
||||
@@ -212,6 +212,29 @@ export interface TemplateConfig {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
|
||||
* when a notification doesn't take the immediate-deliver happy path.
|
||||
*
|
||||
* * ``deferred`` — held back by quiet hours; ``deferred_until`` carries the
|
||||
* UTC ISO datetime at which a drain job will fire.
|
||||
* * ``delivered_after_quiet_hours`` — a drain successfully dispatched the
|
||||
* originally-deferred event. ``original_event_log_id`` points back at the
|
||||
* row from when the event was first detected.
|
||||
* * ``deferred_then_dropped`` — drain time arrived but the link/target was
|
||||
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
|
||||
* * ``deferred_then_failed`` — drain dispatched but the target returned an
|
||||
* error; ``reason`` carries the truncated provider error.
|
||||
* * ``suppressed_quiet_hours_nondeferrable`` — wall-clock event type (e.g.
|
||||
* ``scheduled_message``) caught by quiet hours, dropped on principle.
|
||||
*/
|
||||
export type DispatchStatus =
|
||||
| 'deferred'
|
||||
| 'delivered_after_quiet_hours'
|
||||
| 'deferred_then_dropped'
|
||||
| 'deferred_then_failed'
|
||||
| 'suppressed_quiet_hours_nondeferrable';
|
||||
|
||||
export interface EventLog {
|
||||
id: number;
|
||||
event_type: string;
|
||||
@@ -228,7 +251,12 @@ export interface EventLog {
|
||||
telegram_bot_id?: number | null;
|
||||
bot_name?: string;
|
||||
assets_count: number;
|
||||
details: Record<string, any>;
|
||||
details: Record<string, any> & {
|
||||
dispatch_status?: DispatchStatus;
|
||||
deferred_until?: string;
|
||||
original_event_log_id?: number | null;
|
||||
deferred_for_seconds?: number;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -344,4 +372,38 @@ export interface DashboardStatus {
|
||||
total_events: number;
|
||||
recent_events: EventLog[];
|
||||
command_trackers?: number;
|
||||
/** Provider name → total event count across ALL events matching the
|
||||
* current filters (ignores pagination). Powers the "On watch" deck
|
||||
* when the user opts out of page-scoped stats. */
|
||||
provider_event_counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
||||
|
||||
export interface ReleaseStatus {
|
||||
provider: ReleaseProviderKind;
|
||||
current: string;
|
||||
latest: string | null;
|
||||
latest_tag: string | null;
|
||||
latest_url: string | null;
|
||||
latest_name: string | null;
|
||||
latest_body: string | null;
|
||||
latest_published_at: string | null;
|
||||
latest_prerelease: boolean;
|
||||
checked_at: string | null;
|
||||
update_available: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ReleaseTestResult {
|
||||
ok: boolean;
|
||||
info: {
|
||||
tag: string;
|
||||
version: string;
|
||||
name: string | null;
|
||||
url: string | null;
|
||||
published_at: string | null;
|
||||
prerelease: boolean;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { api } from '$lib/api';
|
||||
import { api, errMsg } from '$lib/api';
|
||||
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
@@ -18,7 +18,7 @@
|
||||
providersCache, notificationTrackersCache, trackingConfigsCache,
|
||||
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
|
||||
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
|
||||
matrixBotsCache, targetsCache,
|
||||
matrixBotsCache, targetsCache, releaseStatusCache,
|
||||
} from '$lib/stores/caches.svelte';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
@@ -31,34 +31,55 @@
|
||||
|
||||
let allProviders = $derived(providersCache.items);
|
||||
|
||||
// Sidebar release indicator — reads from the cache populated in onMount.
|
||||
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
|
||||
// A screen reader hits the brand-version link on every page — keep the
|
||||
// label informative only when an update is available, otherwise announce
|
||||
// the version + product so we don't repeat "Up to date" everywhere.
|
||||
const releaseTooltip = $derived(
|
||||
releaseUpdateAvailable
|
||||
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
|
||||
: `Notify Bridge v${__APP_VERSION__}`
|
||||
);
|
||||
|
||||
let providerFilterItems = $derived([
|
||||
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||
]);
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
let _syncingFilter = false;
|
||||
// One-way: the store is the source of truth, the filter widget displays it.
|
||||
// IconGridSelect mutations route through `onChange` (see template) so we
|
||||
// never need a paired `$effect` to mirror the local <-> store value, which
|
||||
// previously required a `_syncingFilter` reentrancy flag.
|
||||
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
|
||||
|
||||
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||
// hard reload — the most visible "jump" the user reported.
|
||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||
|
||||
// Sync filter value → store
|
||||
// Reconcile a stale persisted provider ID against the freshly-loaded
|
||||
// providers cache. Lives here (not in the store getter) because writing
|
||||
// `_providerId` from a `$state`-derived getter triggers Svelte's
|
||||
// `state_unsafe_mutation`. Runs once per cache refresh.
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
if (_syncingFilter) return;
|
||||
globalProviderFilter.set(v === 0 ? null : v);
|
||||
// Track `fetchedAt` so we re-run after the cache loads.
|
||||
void providersCache.fetchedAt;
|
||||
void providersCache.items.length;
|
||||
globalProviderFilter.reconcileWithCache();
|
||||
});
|
||||
|
||||
// Sync store → filter value (handles auto-clear of stale IDs)
|
||||
$effect(() => {
|
||||
const storeId = globalProviderFilter.id;
|
||||
if (storeId === null && providerFilterValue !== 0) {
|
||||
_syncingFilter = true;
|
||||
providerFilterValue = 0;
|
||||
_syncingFilter = false;
|
||||
}
|
||||
});
|
||||
function setProviderFilter(v: string | number) {
|
||||
const num = typeof v === 'number' ? v : Number(v);
|
||||
globalProviderFilter.set(num === 0 ? null : num);
|
||||
}
|
||||
|
||||
// Collapsed-rail filter cycles through providers via the same setter so the
|
||||
// store stays the single write path.
|
||||
function cycleProviderFilter() {
|
||||
const ids = [0, ...allProviders.map(p => p.id)];
|
||||
const idx = ids.indexOf(providerFilterValue);
|
||||
setProviderFilter(ids[(idx + 1) % ids.length]);
|
||||
}
|
||||
|
||||
let showPasswordForm = $state(false);
|
||||
let redirecting = $state(false);
|
||||
@@ -80,7 +101,7 @@
|
||||
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
|
||||
}
|
||||
|
||||
// Read persisted UI state synchronously so first paint already matches the
|
||||
@@ -306,6 +327,7 @@
|
||||
emailBotsCache.fetch(),
|
||||
matrixBotsCache.fetch(),
|
||||
targetsCache.fetch(),
|
||||
releaseStatusCache.fetch(),
|
||||
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
|
||||
}
|
||||
});
|
||||
@@ -401,7 +423,20 @@
|
||||
{/if}
|
||||
Notify Bridge
|
||||
</h1>
|
||||
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
|
||||
<p class="brand-version font-mono">
|
||||
<a
|
||||
class="brand-version-link"
|
||||
class:has-update={releaseUpdateAvailable}
|
||||
href="/settings#release"
|
||||
aria-label={releaseTooltip}
|
||||
title={releaseUpdateAvailable ? releaseTooltip : undefined}
|
||||
>
|
||||
<span>v{__APP_VERSION__}</span>
|
||||
{#if releaseUpdateAvailable}
|
||||
<span class="brand-version-dot" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -421,18 +456,14 @@
|
||||
{#if showProviderFilter}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
const ids = [0, ...allProviders.map(p => p.id)];
|
||||
const idx = ids.indexOf(providerFilterValue);
|
||||
providerFilterValue = ids[(idx + 1) % ids.length];
|
||||
}}
|
||||
<button onclick={cycleProviderFilter}
|
||||
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
|
||||
title={globalProviderFilter.provider?.name || t('common.allProviders')}
|
||||
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
|
||||
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -570,6 +601,7 @@
|
||||
<NavIcon name="mdiMagnify" size={20} />
|
||||
</button>
|
||||
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
|
||||
aria-expanded={mobileMoreOpen}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||
<NavIcon name="mdiDotsHorizontal" size={20} />
|
||||
@@ -584,7 +616,7 @@
|
||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-3">
|
||||
@@ -772,6 +804,40 @@
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.brand-version-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: 0.3rem;
|
||||
padding: 1px 4px;
|
||||
margin: -1px -4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.brand-version-link:hover {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.brand-version-link.has-update {
|
||||
color: var(--color-citrus, #d4a73a);
|
||||
}
|
||||
.brand-version-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-citrus, #d4a73a);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
|
||||
animation: brand-version-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes brand-version-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.35); opacity: 0.65; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-version-dot { animation: none; }
|
||||
.brand-version-link { transition: none; }
|
||||
}
|
||||
.brand-orb {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 11px;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerStatsModeItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
@@ -76,6 +76,17 @@
|
||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
||||
}
|
||||
|
||||
// "On watch" provider deck stats scope. ``'page'`` = derive counts from
|
||||
// the events visible on the current page (legacy behavior); ``'all'`` =
|
||||
// use the server-aggregated ``provider_event_counts`` map covering every
|
||||
// event that matches the active filters.
|
||||
const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode';
|
||||
function loadProviderStatsMode(): string {
|
||||
if (typeof localStorage === 'undefined') return 'page';
|
||||
const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY);
|
||||
return stored === 'all' ? 'all' : 'page';
|
||||
}
|
||||
|
||||
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
|
||||
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
||||
// this whitelist in sync with that helper so a stale localStorage
|
||||
@@ -95,6 +106,7 @@
|
||||
let eventsLoading = $state(false);
|
||||
let confirmClearEvents = $state(false);
|
||||
let refreshSeconds = $state(loadRefreshSeconds());
|
||||
let providerStatsMode = $state(loadProviderStatsMode());
|
||||
let selectedEvent = $state<EventLog | null>(null);
|
||||
// Stagger entry animation should play once on initial load only —
|
||||
// without this, every pagination/filter change re-runs the cascade
|
||||
@@ -128,6 +140,14 @@
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
|
||||
});
|
||||
|
||||
// Persist the provider deck stats mode the same way.
|
||||
let _providerStatsHydrated = false;
|
||||
$effect(() => {
|
||||
const v = providerStatsMode;
|
||||
if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; }
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v);
|
||||
});
|
||||
|
||||
async function clearEvents() {
|
||||
try {
|
||||
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
||||
@@ -197,6 +217,7 @@
|
||||
targets: next.targets,
|
||||
total_events: next.total_events,
|
||||
command_trackers: next.command_trackers,
|
||||
provider_event_counts: next.provider_event_counts,
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -298,9 +319,21 @@
|
||||
: displayProviders);
|
||||
|
||||
// === Provider deck — derive activity counts from recent events ===
|
||||
//
|
||||
// ``providerStatsMode`` controls the scope: ``'page'`` derives counts
|
||||
// from the visible page (legacy), ``'all'`` uses the server-aggregated
|
||||
// ``provider_event_counts`` map covering every event under the active
|
||||
// filters regardless of pagination.
|
||||
const providerEventCounts = $derived.by(() => {
|
||||
const counts = new Map<string, number>();
|
||||
if (!status) return counts;
|
||||
if (providerStatsMode === 'all' && status.provider_event_counts) {
|
||||
for (const [name, total] of Object.entries(status.provider_event_counts)) {
|
||||
if (!name) continue;
|
||||
counts.set(name, total);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
for (const ev of status.recent_events) {
|
||||
const k = ev.provider_name || '';
|
||||
if (!k) continue;
|
||||
@@ -724,6 +757,37 @@
|
||||
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.details?.dispatch_status === 'deferred' && event.details?.deferred_until}
|
||||
<span class="dispatch-badge dispatch-badge--deferred"
|
||||
title={t('dashboard.deferredTitle')}>
|
||||
<MdiIcon name="mdiPauseCircleOutline" size={12} />
|
||||
{t('dashboard.heldUntil')} {timeShort(event.details.deferred_until)}
|
||||
</span>
|
||||
{:else if event.details?.dispatch_status === 'delivered_after_quiet_hours'}
|
||||
<span class="dispatch-badge dispatch-badge--late"
|
||||
title={t('dashboard.deliveredLateTitle')}>
|
||||
<MdiIcon name="mdiClockCheckOutline" size={12} />
|
||||
{t('dashboard.deliveredLate')}
|
||||
</span>
|
||||
{:else if event.details?.dispatch_status === 'deferred_then_dropped'}
|
||||
<span class="dispatch-badge dispatch-badge--dropped"
|
||||
title={t('dashboard.deferredThenDroppedTitle')}>
|
||||
<MdiIcon name="mdiCloseCircleOutline" size={12} />
|
||||
{t('dashboard.deferredThenDropped')}
|
||||
</span>
|
||||
{:else if event.details?.dispatch_status === 'deferred_then_failed'}
|
||||
<span class="dispatch-badge dispatch-badge--dropped"
|
||||
title={event.details?.reason ?? ''}>
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={12} />
|
||||
{t('dashboard.deferredThenFailed')}
|
||||
</span>
|
||||
{:else if event.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
|
||||
<span class="dispatch-badge dispatch-badge--dropped"
|
||||
title={t('dashboard.suppressedNondeferrableTitle')}>
|
||||
<MdiIcon name="mdiVolumeOff" size={12} />
|
||||
{t('dashboard.suppressedQuietHours')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if event.event_type?.startsWith('command_')}
|
||||
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
|
||||
{@const issuerLabel = issuer
|
||||
@@ -781,11 +845,18 @@
|
||||
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
|
||||
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => toggleSection('on_watch')}
|
||||
class="ghost-icon-btn"
|
||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||
</button>
|
||||
<div class="panel-head-actions">
|
||||
<div class="w-32" title={t('dashboard.statsModeTitle')}>
|
||||
<IconGridSelect items={providerStatsModeItems()}
|
||||
bind:value={providerStatsMode}
|
||||
columns={2} compact />
|
||||
</div>
|
||||
<button type="button" onclick={() => toggleSection('on_watch')}
|
||||
class="ghost-icon-btn"
|
||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if sectionExpanded.on_watch}
|
||||
@@ -1334,6 +1405,36 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
.signal-trail .arrow { color: var(--color-muted-foreground); }
|
||||
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
|
||||
* Coloured to match the verb (held = primary glow, late = success, drop
|
||||
* = muted). The icon is intentionally small so the badge doesn't pull
|
||||
* focus from the event verb itself. */
|
||||
.dispatch-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
margin-left: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dispatch-badge--deferred {
|
||||
color: var(--color-primary);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
|
||||
}
|
||||
.dispatch-badge--late {
|
||||
color: var(--color-success, #16a34a);
|
||||
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
|
||||
}
|
||||
.dispatch-badge--dropped {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.signal-when {
|
||||
text-align: right;
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -22,6 +22,7 @@
|
||||
import ExecutionHistory from './ExecutionHistory.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { Action, ActionRule } from '$lib/types';
|
||||
|
||||
let allActions = $derived(actionsCache.items);
|
||||
@@ -98,8 +99,8 @@
|
||||
capabilitiesCache.fetch(),
|
||||
]);
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('actions.loadError');
|
||||
} catch (err: unknown) {
|
||||
loadError = errMsg(err, t('actions.loadError'));
|
||||
} finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@
|
||||
}
|
||||
showForm = false; editing = null; actionsCache.invalidate(); await load();
|
||||
snackSuccess(t('actions.saved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@
|
||||
await api(`/actions/${id}`, { method: 'DELETE' });
|
||||
actionsCache.invalidate(); await load();
|
||||
snackSuccess(t('actions.deleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function executeAction(id: number, dryRun = false) {
|
||||
@@ -164,7 +165,7 @@
|
||||
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
|
||||
snackSuccess(msg);
|
||||
actionsCache.invalidate(); await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
executing = { ...executing, [id]: false };
|
||||
}
|
||||
|
||||
@@ -193,6 +194,51 @@
|
||||
if (status === 'failed') return 'var(--color-error-fg)';
|
||||
return 'var(--color-muted-foreground)';
|
||||
}
|
||||
|
||||
function statusTone(status: string | undefined): MetaTile['tone'] {
|
||||
if (status === 'success') return 'mint';
|
||||
if (status === 'partial') return 'citrus';
|
||||
if (status === 'failed') return 'coral';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function actionTiles(action: Action): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push(action.enabled
|
||||
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('commandTracker.disabled'), tone: 'default' });
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: getProviderName(action.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiTagOutline',
|
||||
label: action.action_type,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: action.schedule_type === 'cron' ? 'mdiClockOutline' : 'mdiTimerOutline',
|
||||
label: formatSchedule(action),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiFormatListBulleted',
|
||||
value: String(action.rules?.length || 0),
|
||||
label: t('actions.rules'),
|
||||
tone: (action.rules?.length || 0) > 0 ? 'sky' : 'default',
|
||||
});
|
||||
if (action.last_run_status) {
|
||||
tiles.push({
|
||||
icon: 'mdiHistory',
|
||||
label: action.last_run_status,
|
||||
tone: statusTone(action.last_run_status),
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
@@ -323,32 +369,35 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each actions as action}
|
||||
<Card hover entityId={action.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{action.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
|
||||
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
||||
<span>{formatSchedule(action)}</span>
|
||||
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
||||
{#if action.last_run_status}
|
||||
<span style="color: {statusColor(action.last_run_status)}">
|
||||
{action.last_run_status}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<p class="font-medium truncate">{action.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{action.action_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
||||
<span>{formatSchedule(action)}</span>
|
||||
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
||||
{#if action.last_run_status}
|
||||
<span style="color: {statusColor(action.last_run_status)}">
|
||||
{action.last_run_status}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={actionTiles(action)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPlay" title={t('actions.execute')}
|
||||
onclick={() => executeAction(action.id)}
|
||||
disabled={executing[action.id]} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache } from '$lib/stores/caches.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
@@ -47,7 +47,7 @@
|
||||
loading = true;
|
||||
try {
|
||||
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
people = Array.isArray(p) ? p : [];
|
||||
albums = Array.isArray(a) ? a : [];
|
||||
} catch {
|
||||
// People/album endpoints may not exist yet — degrade gracefully
|
||||
// People/album endpoints may not exist yet — degrade gracefully
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
resetNewRule();
|
||||
await loadRules();
|
||||
snackSuccess(t('actions.ruleSaved'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
});
|
||||
await loadRules();
|
||||
snackSuccess(t('actions.ruleSaved'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function deleteRule(ruleId: number) {
|
||||
@@ -99,7 +99,7 @@
|
||||
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
|
||||
await loadRules();
|
||||
snackSuccess(t('actions.ruleDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function toggleRule(rule: ActionRule) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
@@ -29,7 +29,7 @@
|
||||
emailBotsCache.fetch(true),
|
||||
matrixBotsCache.fetch(true),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -13,6 +13,7 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { EmailBot } from '$lib/types';
|
||||
|
||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||
@@ -39,6 +40,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
function emailBotTiles(bot: EmailBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiEmailOutline',
|
||||
label: bot.email,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiServerNetwork',
|
||||
label: `${bot.smtp_host}:${bot.smtp_port}`,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
if (bot.smtp_use_tls) {
|
||||
tiles.push({
|
||||
icon: 'mdiLockOutline',
|
||||
label: 'TLS',
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
|
||||
function editEmailBot(bot: EmailBot) {
|
||||
emailForm = {
|
||||
@@ -64,7 +89,7 @@
|
||||
snackSuccess(t('snack.emailBotCreated'));
|
||||
}
|
||||
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
finally { emailSubmitting = false; }
|
||||
}
|
||||
|
||||
@@ -74,10 +99,10 @@
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
finally { confirmDeleteEmail = null; }
|
||||
}
|
||||
@@ -90,7 +115,7 @@
|
||||
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||
else snackError(res.error || t('emailBot.operationFailed'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
emailTesting = { ...emailTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
@@ -165,16 +190,16 @@
|
||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each emailBots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
|
||||
{#if bot.smtp_use_tls}
|
||||
@@ -182,7 +207,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={emailBotTiles(bot)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -13,6 +13,7 @@
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { MatrixBot } from '$lib/types';
|
||||
|
||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||
@@ -38,6 +39,28 @@
|
||||
}
|
||||
});
|
||||
|
||||
function matrixBotTiles(bot: MatrixBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
let host = bot.homeserver_url;
|
||||
try { host = new URL(bot.homeserver_url).host; } catch { /* keep raw */ }
|
||||
tiles.push({
|
||||
icon: 'mdiServerNetwork',
|
||||
label: host,
|
||||
hint: bot.homeserver_url,
|
||||
href: bot.homeserver_url,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
if (bot.display_name) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountCircleOutline',
|
||||
label: bot.display_name,
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
|
||||
function editMatrixBot(bot: MatrixBot) {
|
||||
matrixForm = {
|
||||
@@ -62,7 +85,7 @@
|
||||
snackSuccess(t('snack.matrixBotCreated'));
|
||||
}
|
||||
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
finally { matrixSubmitting = false; }
|
||||
}
|
||||
|
||||
@@ -72,10 +95,10 @@
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
finally { confirmDeleteMatrix = null; }
|
||||
}
|
||||
@@ -88,7 +111,7 @@
|
||||
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||
else snackError(res.error || t('matrixBot.operationFailed'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
matrixTesting = { ...matrixTesting, [botId]: false };
|
||||
}
|
||||
</script>
|
||||
@@ -148,23 +171,24 @@
|
||||
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each matrixBots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
|
||||
{#if bot.display_name}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={matrixBotTiles(bot)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { slide, fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -16,6 +16,7 @@
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { TelegramBot, TelegramChat } from '$lib/types';
|
||||
|
||||
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
|
||||
@@ -60,6 +61,36 @@
|
||||
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
|
||||
let botListenerLoading = $state<Record<number, boolean>>({});
|
||||
|
||||
function telegramBotTiles(bot: TelegramBot): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const mode = bot.update_mode || 'none';
|
||||
const modeTone: MetaTile['tone'] = mode === 'webhook' ? 'lavender' : mode === 'polling' ? 'mint' : 'default';
|
||||
const modeLabel = mode === 'webhook' ? t('telegramBot.webhook') : mode === 'polling' ? t('telegramBot.polling') : t('telegramBot.none');
|
||||
tiles.push({
|
||||
icon: mode === 'webhook' ? 'mdiWebhook' : mode === 'polling' ? 'mdiSync' : 'mdiPowerOff',
|
||||
label: modeLabel,
|
||||
tone: modeTone,
|
||||
});
|
||||
if (bot.bot_username) {
|
||||
tiles.push({
|
||||
icon: 'mdiAt',
|
||||
label: bot.bot_username,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
const chatCount = chats[bot.id]?.length;
|
||||
if (chatCount !== undefined) {
|
||||
tiles.push({
|
||||
icon: 'mdiChat',
|
||||
value: String(chatCount),
|
||||
label: t('telegramBot.chats'),
|
||||
tone: chatCount > 0 ? 'orchid' : 'default',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
|
||||
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
|
||||
|
||||
@@ -74,7 +105,7 @@
|
||||
snackSuccess(t('snack.botRegistered'));
|
||||
}
|
||||
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
@@ -84,10 +115,10 @@
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
@@ -116,7 +147,7 @@
|
||||
try {
|
||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||
}
|
||||
|
||||
@@ -125,11 +156,15 @@
|
||||
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
||||
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
|
||||
snackSuccess(t('telegramBot.chatDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
const LANG_ITEMS = [
|
||||
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
|
||||
// `desc` is the only locale-sensitive field — language *names* are intentionally
|
||||
// shown in their own language (English under EN, Русский under RU, …) so users
|
||||
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
|
||||
// for the no-override row is translated.
|
||||
let LANG_ITEMS = $derived([
|
||||
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
|
||||
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
|
||||
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
|
||||
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
|
||||
@@ -146,7 +181,7 @@
|
||||
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
|
||||
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
|
||||
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
|
||||
];
|
||||
]);
|
||||
|
||||
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
|
||||
try {
|
||||
@@ -158,7 +193,7 @@
|
||||
c.id === chat.id ? { ...c, language_override: lang } : c
|
||||
);
|
||||
snackSuccess(t('telegramBot.languageUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
||||
@@ -171,7 +206,7 @@
|
||||
chats[botId] = (chats[botId] || []).map(c =>
|
||||
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
|
||||
);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function loadListenerStatus(botId: number) {
|
||||
@@ -201,7 +236,7 @@
|
||||
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
|
||||
),
|
||||
};
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
@@ -210,7 +245,7 @@
|
||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
@@ -223,7 +258,7 @@
|
||||
await loadWebhookStatus(botId);
|
||||
}
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
@@ -243,7 +278,7 @@
|
||||
} else {
|
||||
snackError(res.error || t('telegramBot.webhookFailed'));
|
||||
}
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
@@ -253,7 +288,7 @@
|
||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
||||
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
@@ -284,7 +319,7 @@
|
||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
chatTesting = { ...chatTesting, [key]: false };
|
||||
}
|
||||
|
||||
@@ -343,18 +378,19 @@
|
||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each bots as bot}
|
||||
<Card hover entityId={bot.id}>
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||
<p class="font-medium truncate">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
<!-- Mode badge -->
|
||||
</div>
|
||||
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
|
||||
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||
: (bot.update_mode || 'none') === 'polling'
|
||||
@@ -362,10 +398,11 @@
|
||||
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
|
||||
</span>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
|
||||
<MetaStrip tiles={telegramBotTiles(bot)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
disabled={chatsLoading[bot.id]}
|
||||
@@ -431,6 +468,10 @@
|
||||
</div>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={chat.commands_enabled}
|
||||
aria-label={t('telegramBot.commandsToggle')}
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
@@ -478,6 +519,10 @@
|
||||
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={trk.enabled}
|
||||
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -22,6 +22,7 @@
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { CommandConfig } from '$lib/types';
|
||||
|
||||
function templateName(id: number | null): string {
|
||||
@@ -104,10 +105,46 @@
|
||||
commandTemplateConfigsCache.fetch(),
|
||||
capabilitiesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: cfg.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const cmdCount = (cfg.enabled_commands || []).length;
|
||||
tiles.push({
|
||||
icon: 'mdiSlashForward',
|
||||
value: String(cmdCount),
|
||||
label: t('commandConfig.commands'),
|
||||
tone: cmdCount > 0 ? 'mint' : 'coral',
|
||||
});
|
||||
tiles.push({
|
||||
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
|
||||
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
|
||||
tone: 'sky',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiNumeric',
|
||||
value: String(cfg.default_count),
|
||||
label: t('commandConfig.defaultCount'),
|
||||
tone: 'citrus',
|
||||
});
|
||||
if (cfg.command_template_config_id) {
|
||||
tiles.push({
|
||||
icon: 'mdiCodeBracesBox',
|
||||
label: templateName(cfg.command_template_config_id),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
// Auto-select first provider type with commands
|
||||
@@ -177,7 +214,7 @@
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
}
|
||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
@@ -190,10 +227,10 @@
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandConfigDeleted'));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
snackError(err.message);
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
@@ -316,22 +353,20 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as cfg}
|
||||
<Card hover entityId={cfg.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{cfg.provider_type}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<div class="flex items-center gap-2 mt-0.5 list-row__secondary">
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">
|
||||
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
· {t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
· {t('commandConfig.defaultCount')}: {cfg.default_count}
|
||||
</span>
|
||||
{#if cfg.command_template_config_id}
|
||||
@@ -339,7 +374,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={commandConfigTiles(cfg)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
@@ -27,6 +27,7 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
interface CmdTemplateConfig {
|
||||
id: number;
|
||||
@@ -212,8 +213,8 @@
|
||||
allCmdTplConfigs = cfgs;
|
||||
allCapabilities = caps;
|
||||
varsRef = vars;
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.loadError');
|
||||
} catch (err: unknown) {
|
||||
error = errMsg(err, t('common.loadError'));
|
||||
snackError(error);
|
||||
} finally {
|
||||
loaded = true;
|
||||
@@ -262,6 +263,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: config.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const slotCount = Object.keys(config.slots || {}).length;
|
||||
tiles.push({
|
||||
icon: 'mdiViewGridOutline',
|
||||
value: String(slotCount),
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
}
|
||||
if (locales.size > 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiTranslate',
|
||||
value: String(locales.size),
|
||||
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
|
||||
hint: [...locales].sort().join(', '),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
if (config.user_id === 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiShieldStarOutline',
|
||||
label: t('common.system'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
||||
@@ -315,9 +354,10 @@
|
||||
editing = null;
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateSaved'));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
const m = errMsg(err);
|
||||
error = m;
|
||||
snackError(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +408,8 @@
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,11 +445,12 @@
|
||||
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
const m = errMsg(err);
|
||||
error = m;
|
||||
snackError(m);
|
||||
} finally {
|
||||
confirmDelete = null;
|
||||
}
|
||||
@@ -548,7 +589,7 @@
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
@@ -587,25 +628,25 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
|
||||
{/if}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -21,6 +21,7 @@
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { providerDefaultIcon } from '$lib/grid-items';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||
|
||||
let allCmdTrackers = $state<any[]>([]);
|
||||
@@ -106,7 +107,7 @@
|
||||
providersCache.fetch(), commandConfigsCache.fetch(),
|
||||
telegramBotsCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
|
||||
@@ -167,7 +168,7 @@
|
||||
snackSuccess(t('snack.commandTrackerCreated'));
|
||||
}
|
||||
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@
|
||||
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandTrackerDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
@@ -192,7 +193,7 @@
|
||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
toggling = { ...toggling, [trk.id]: false };
|
||||
}
|
||||
|
||||
@@ -225,7 +226,7 @@
|
||||
snackSuccess(t('snack.listenerAdded'));
|
||||
await loadListeners(trkId);
|
||||
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
addingListener = { ...addingListener, [trkId]: false };
|
||||
}
|
||||
|
||||
@@ -234,7 +235,7 @@
|
||||
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
||||
snackSuccess(t('snack.listenerRemoved'));
|
||||
await loadListeners(trkId);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
// Per-listener album scope editing
|
||||
@@ -263,7 +264,7 @@
|
||||
snackSuccess(t('snack.listenerScopeSaved'));
|
||||
await loadListeners(scopeEditor.trkId);
|
||||
scopeEditor = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
function providerName(id: number): string {
|
||||
@@ -272,6 +273,32 @@
|
||||
function configName(id: number): string {
|
||||
return commandConfigs.find(c => c.id === id)?.name || '?';
|
||||
}
|
||||
|
||||
function commandTrackerTiles(trk: any): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push(trk.enabled
|
||||
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
|
||||
: { icon: 'mdiCloseCircle', label: t('commandTracker.disabled'), tone: 'coral' });
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: providerName(trk.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiCog',
|
||||
label: configName(trk.command_config_id),
|
||||
tone: 'sky',
|
||||
});
|
||||
if (trk.listener_count !== undefined) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountMultipleOutline',
|
||||
value: String(trk.listener_count),
|
||||
label: t('commandTracker.listeners').toLowerCase(),
|
||||
tone: trk.listener_count > 0 ? 'orchid' : 'default',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
@@ -341,34 +368,37 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each trackers as trk}
|
||||
<Card hover entityId={trk.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{trk.name}</p>
|
||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium truncate">{trk.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
|
||||
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{#if trk.listener_count !== undefined}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||
{#if trk.listener_count !== undefined}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={commandTrackerTiles(trk)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
|
||||
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
|
||||
<button onclick={() => toggleListeners(trk.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
|
||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
||||
</div>
|
||||
@@ -443,7 +473,7 @@
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||
{t('backup.selectAll')}
|
||||
</button>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span aria-hidden="true">В·</span>
|
||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||
{t('backup.deselectAll')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { login } from '$lib/auth.svelte';
|
||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
@@ -37,7 +37,7 @@
|
||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||
if (res.needs_setup) goto('/setup');
|
||||
} catch {
|
||||
// The backend is unreachable — surface that distinctly so the user
|
||||
// The backend is unreachable — surface that distinctly so the user
|
||||
// doesn't blame the login form for a network/backend problem.
|
||||
backendDown = true;
|
||||
}
|
||||
@@ -50,8 +50,8 @@
|
||||
try {
|
||||
await login(username, password);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
error = err.message || t('auth.loginFailed');
|
||||
} catch (err: unknown) {
|
||||
error = errMsg(err, t('auth.loginFailed'));
|
||||
}
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { api, parseDate , errMsg} from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
@@ -22,6 +22,7 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
||||
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import TrackerForm from './TrackerForm.svelte';
|
||||
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
||||
import SharedLinkModal from './SharedLinkModal.svelte';
|
||||
@@ -165,8 +166,8 @@
|
||||
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||
capabilitiesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('common.loadFailed');
|
||||
} catch (err: unknown) {
|
||||
loadError = errMsg(err, t('common.loadFailed'));
|
||||
snackError(loadError);
|
||||
} finally { loaded = true; highlightFromUrl(); }
|
||||
}
|
||||
@@ -288,7 +289,7 @@
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; linkWarning = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
|
||||
}
|
||||
|
||||
async function autoCreateLinks() {
|
||||
@@ -300,8 +301,8 @@
|
||||
try {
|
||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
||||
created++;
|
||||
} catch (err: any) {
|
||||
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
||||
} catch (err: unknown) {
|
||||
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +324,7 @@
|
||||
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
}
|
||||
|
||||
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
|
||||
@@ -334,7 +335,7 @@
|
||||
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
confirmDelete = null;
|
||||
}
|
||||
|
||||
@@ -374,6 +375,54 @@
|
||||
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta tiles for a tracker row. Visible on lg+ in the dead middle space
|
||||
* between identity and actions. Mirrors the secondary text shown on narrow
|
||||
* screens, but as live tiles users can scan at a glance.
|
||||
*/
|
||||
function trackerTiles(tracker: Tracker): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const trkDesc = getDescriptor(getProviderType(tracker));
|
||||
// Status — armed/paused with color tone
|
||||
tiles.push(tracker.enabled
|
||||
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
|
||||
// Provider
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: getProviderName(tracker.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
const collCount = (tracker.collection_ids || []).length;
|
||||
if (collCount > 0 || !trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiFolderMultipleOutline',
|
||||
value: String(collCount),
|
||||
label: getCollectionLabel(tracker),
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
if (!trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiTimerOutline',
|
||||
value: `${tracker.scan_interval}s`,
|
||||
label: t('notificationTracker.every').trim(),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
// Linked targets
|
||||
const tgtCount = (tracker.tracker_targets || []).length;
|
||||
tiles.push({
|
||||
icon: 'mdiTarget',
|
||||
value: String(tgtCount),
|
||||
label: t('notificationTracker.linkedTargets'),
|
||||
tone: tgtCount > 0 ? 'mint' : 'coral',
|
||||
});
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
||||
const pt = getProviderType(tracker);
|
||||
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
||||
@@ -402,7 +451,7 @@
|
||||
newLinkTemplateConfigId[trackerId] = 0;
|
||||
await load();
|
||||
snackSuccess(t('snack.targetLinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
addingTarget = { ...addingTarget, [trackerId]: false };
|
||||
}
|
||||
|
||||
@@ -411,7 +460,7 @@
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetUnlinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
||||
@@ -421,7 +470,7 @@
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
});
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
@@ -443,8 +492,8 @@
|
||||
} else {
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
ttTesting = { ...ttTesting, [key]: '' };
|
||||
}
|
||||
@@ -528,27 +577,30 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each notificationTrackers as tracker (tracker.id)}
|
||||
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||
<Card hover entityId={tracker.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium truncate">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||
</span>
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
<div class="list-row__secondary mt-0.5">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<MetaStrip tiles={trackerTiles(tracker)} />
|
||||
<div class="list-row__actions flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { api , errMsg} from '$lib/api';
|
||||
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
@@ -23,7 +23,7 @@
|
||||
let replacing = $state<Record<string, boolean>>({});
|
||||
|
||||
/**
|
||||
* Expired and password-protected links can't be repaired in place — the
|
||||
* Expired and password-protected links can't be repaired in place — the
|
||||
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
||||
* link (which the backend does by POSTing a new one and returning it).
|
||||
* We surface the action per-row so users don't have to leave the form.
|
||||
@@ -39,8 +39,8 @@
|
||||
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||
if (onupdate) onupdate(remaining);
|
||||
} catch (err: any) {
|
||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
|
||||
} finally {
|
||||
replacing = { ...replacing, [album.id]: false };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
interface Props {
|
||||
@@ -58,9 +59,36 @@
|
||||
}: Props = $props();
|
||||
|
||||
let descriptor = $derived(getDescriptor(providerType));
|
||||
let isScheduler = $derived(providerType === 'scheduler');
|
||||
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
||||
let colMeta = $derived(descriptor?.collectionMeta);
|
||||
// Providers without a collection (currently just the scheduler) drive the
|
||||
// scheduler-specific form layout. Reading from the descriptor keeps the
|
||||
// branch out of the component and lets a future provider opt in by
|
||||
// declaring `collectionMeta: null`.
|
||||
let isScheduler = $derived(colMeta == null);
|
||||
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
||||
|
||||
/**
|
||||
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
|
||||
* descriptor-declared CTA href. When the corresponding form field is unset
|
||||
* (value 0), strip the entire `?edit=...` query so the link still goes to
|
||||
* the list page. Centralising this here avoids per-provider href logic
|
||||
* leaking back into the template.
|
||||
*/
|
||||
function resolveHintHref(href: string): string {
|
||||
const tcId = form.default_tracking_config_id;
|
||||
const tplId = form.default_template_config_id;
|
||||
if (href.includes('{tracking_config_id}')) {
|
||||
return tcId
|
||||
? href.replace('{tracking_config_id}', String(tcId))
|
||||
: href.split('?')[0];
|
||||
}
|
||||
if (href.includes('{template_config_id}')) {
|
||||
return tplId
|
||||
? href.replace('{template_config_id}', String(tplId))
|
||||
: href.split('?')[0];
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
// Custom variable management for scheduler
|
||||
function addVariable() {
|
||||
@@ -123,14 +151,24 @@
|
||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||
{#each descriptor.userFilters as uf (uf.key)}
|
||||
{@const filterKey = uf.filterKey ?? uf.key}
|
||||
<div>
|
||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[uf.key] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
{#if uf.inputMode === 'tags'}
|
||||
<TagInput
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
icon={uf.icon}
|
||||
/>
|
||||
{:else}
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -220,29 +258,27 @@
|
||||
{/if}
|
||||
|
||||
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||
live on the tracking config, not on the tracker itself. Surface this
|
||||
here so users don't have to stumble onto the feature by reading docs. -->
|
||||
{#if providerType === 'immich'}
|
||||
live on the tracking config, not on the tracker itself. The hint
|
||||
content (message + CTAs) is declared on the provider descriptor so
|
||||
each provider can surface its own discoverability links without
|
||||
embedding `if (type === 'xyz')` here. -->
|
||||
{#if descriptor?.featureDiscoveryHint}
|
||||
{@const hint = descriptor.featureDiscoveryHint}
|
||||
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||
<div class="flex-1 text-xs">
|
||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
<a href={form.default_tracking_config_id
|
||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||
: '/tracking-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
<a href={form.default_template_config_id
|
||||
? `/template-configs?edit=${form.default_template_config_id}`
|
||||
: '/template-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTemplateConfig')}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
|
||||
{#if hint.ctas && hint.ctas.length > 0}
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
{#each hint.ctas as cta}
|
||||
<a href={resolveHintHref(cta.href)}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
|
||||
{t(cta.labelKey)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -25,6 +25,7 @@
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
||||
import type { ServiceProvider } from '$lib/types';
|
||||
|
||||
@@ -52,6 +53,67 @@
|
||||
return externalUrl ? `${externalUrl}${path}` : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build meta tiles for a provider row. Filled into the dead middle space
|
||||
* on wide displays; on narrow screens the secondary text line takes over.
|
||||
*/
|
||||
function providerTiles(provider: ServiceProvider): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const h = health[provider.id];
|
||||
const provDesc = getDescriptor(provider.type);
|
||||
// Status — first tile, color-coded
|
||||
if (h === true) {
|
||||
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
|
||||
} else if (h === false) {
|
||||
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
|
||||
} else {
|
||||
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
|
||||
}
|
||||
// Type / connection address
|
||||
const cfg = provider.config as Record<string, any> | undefined;
|
||||
if (cfg?.url) {
|
||||
tiles.push({
|
||||
icon: 'mdiLinkVariant',
|
||||
label: shortenUrl(cfg.url),
|
||||
hint: cfg.url,
|
||||
href: cfg.url,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
} else if (cfg?.host) {
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: `${cfg.host}:${cfg.port || 3493}`,
|
||||
tone: 'sky',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook URL (copy to clipboard)
|
||||
if (provDesc?.webhookUrlPattern) {
|
||||
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
|
||||
tiles.push({
|
||||
icon: 'mdiContentCopy',
|
||||
label: t('providers.webhookUrl'),
|
||||
hint: webhookUrl,
|
||||
tone: 'orchid',
|
||||
onclick: (e) => copyWebhookUrl(e, webhookUrl),
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
|
||||
function shortenUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const segments = u.pathname.split('/').filter(Boolean);
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
return `${u.host}${tail}`;
|
||||
} catch {
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
}
|
||||
}
|
||||
|
||||
function copyWebhookUrl(e: Event, url: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -107,8 +169,8 @@
|
||||
try {
|
||||
await providersCache.fetch(true);
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('providers.loadError');
|
||||
} catch (err: unknown) {
|
||||
loadError = errMsg(err, t('providers.loadError'));
|
||||
} finally { loaded = true; highlightFromUrl(); }
|
||||
// Ping all providers in background (use unfiltered list)
|
||||
for (const p of allProviders) {
|
||||
@@ -175,7 +237,7 @@
|
||||
}
|
||||
showForm = false; editing = null; providersCache.invalidate(); await load();
|
||||
snackSuccess(t('snack.providerSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -186,10 +248,10 @@
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -222,7 +284,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<div in:slide={{ duration: 200 }} class="list-stack">
|
||||
<Card class="mb-6">
|
||||
<ErrorBanner message={error} />
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
@@ -259,6 +321,11 @@
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||
min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || (field.required === 'create-only' && !editing)}
|
||||
@@ -292,9 +359,11 @@
|
||||
{/if}
|
||||
|
||||
{#if !showForm && allProviders.length > 0}
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="list-stack mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -307,37 +376,43 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each providers as provider}
|
||||
{@const provDesc = getDescriptor(provider.type)}
|
||||
<Card hover entityId={provider.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{provider.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<p class="font-medium truncate">{provider.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
|
||||
</div>
|
||||
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
|
||||
<div class="list-row__secondary">
|
||||
{#if provider.config?.url}
|
||||
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
|
||||
{:else if provider.config?.host}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||
{/if}
|
||||
{#if provDesc?.webhookUrlPattern}
|
||||
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
|
||||
{t('providers.webhookUrl')}:
|
||||
<button type="button"
|
||||
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
|
||||
title={t('providers.webhookUrlCopyTitle')}
|
||||
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if provider.config?.url}
|
||||
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
|
||||
{:else if provider.config?.host}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
|
||||
{/if}
|
||||
{#if provDesc?.webhookUrlPattern}
|
||||
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
|
||||
{t('providers.webhookUrl')}:
|
||||
<button type="button"
|
||||
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
|
||||
title={t('providers.webhookUrlCopyTitle')}
|
||||
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={providerTiles(provider)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
{:else if field.type === 'number'}
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || field.required === 'create-only'}
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { externalUrlCache } from '$lib/stores/caches.svelte';
|
||||
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
|
||||
import SettingsHero from './SettingsHero.svelte';
|
||||
import IdentityCassette from './IdentityCassette.svelte';
|
||||
import TelegramCassette from './TelegramCassette.svelte';
|
||||
import ReleaseCassette from './ReleaseCassette.svelte';
|
||||
import CacheLedger from './CacheLedger.svelte';
|
||||
import LoggingCassette from './LoggingCassette.svelte';
|
||||
import SaveBar from './SaveBar.svelte';
|
||||
@@ -36,6 +37,11 @@
|
||||
log_level: string;
|
||||
log_format: string;
|
||||
log_levels: string;
|
||||
release_provider_kind: string;
|
||||
release_provider_url: string;
|
||||
release_provider_repo: string;
|
||||
release_include_prereleases: string;
|
||||
release_check_interval_hours: string;
|
||||
}
|
||||
|
||||
const EMPTY: Settings = {
|
||||
@@ -48,6 +54,11 @@
|
||||
log_level: 'INFO',
|
||||
log_format: 'text',
|
||||
log_levels: '',
|
||||
release_provider_kind: 'gitea',
|
||||
release_provider_url: 'https://git.dolgolyov-family.by',
|
||||
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
|
||||
release_include_prereleases: '0',
|
||||
release_check_interval_hours: '12',
|
||||
};
|
||||
|
||||
let loaded = $state(false);
|
||||
@@ -86,6 +97,8 @@
|
||||
settings = { ...EMPTY, ...fetched };
|
||||
baseline = { ...settings };
|
||||
await loadCacheStats();
|
||||
// Warm the release status so the cassette renders the strip on first paint.
|
||||
await releaseStatusCache.fetch();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
error = msg;
|
||||
@@ -108,6 +121,12 @@
|
||||
settings = { ...EMPTY, ...next };
|
||||
baseline = { ...settings };
|
||||
externalUrlCache.invalidate();
|
||||
// Release config may have changed → drop the cached status and
|
||||
// refetch so the sidebar badge + cassette strip reflect the
|
||||
// freshly-rescheduled probe without waiting for the next route
|
||||
// change to trigger another read.
|
||||
releaseStatusCache.invalidate();
|
||||
void releaseStatusCache.fetch(true);
|
||||
snackSuccess(t('settings.saved'));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||
@@ -171,6 +190,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReleaseCassette
|
||||
bind:providerKind={settings.release_provider_kind}
|
||||
bind:providerUrl={settings.release_provider_url}
|
||||
bind:providerRepo={settings.release_provider_repo}
|
||||
bind:includePrereleases={settings.release_include_prereleases}
|
||||
bind:checkIntervalHours={settings.release_check_interval_hours}
|
||||
/>
|
||||
|
||||
<LoggingCassette
|
||||
bind:logLevel={settings.log_level}
|
||||
bind:logFormat={settings.log_format}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||
|
||||
@@ -104,6 +105,7 @@
|
||||
{#if totalBytes > 0}
|
||||
<span class="ledger-sep">·</span>
|
||||
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
|
||||
<Hint text={t('settings.cacheStatsHint')} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
// All five fields are persisted as strings via the /settings PUT —
|
||||
// the parent owns the boundary type. Bool flags use "0" / "1".
|
||||
providerKind: string;
|
||||
providerUrl: string;
|
||||
providerRepo: string;
|
||||
includePrereleases: string;
|
||||
checkIntervalHours: string;
|
||||
}
|
||||
|
||||
let {
|
||||
providerKind = $bindable(),
|
||||
providerUrl = $bindable(),
|
||||
providerRepo = $bindable(),
|
||||
includePrereleases = $bindable(),
|
||||
checkIntervalHours = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let checking = $state(false);
|
||||
let testing = $state(false);
|
||||
let testResult = $state<ReleaseTestResult | null>(null);
|
||||
|
||||
const status = $derived(releaseStatusCache.value);
|
||||
const prereleaseChecked = $derived(includePrereleases === '1');
|
||||
const isDisabled = $derived(providerKind === 'disabled');
|
||||
|
||||
// Stale Test-result on input change is misleading — wipe whenever any of
|
||||
// the probed parameters change so the strip reflects "current" state.
|
||||
$effect(() => {
|
||||
// Touch each parameter to register dependency.
|
||||
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
|
||||
testResult = null;
|
||||
});
|
||||
|
||||
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
|
||||
|
||||
const stateTone: Tone = $derived.by(() => {
|
||||
if (!status) return 'sky';
|
||||
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
|
||||
if (status.update_available) return 'citrus';
|
||||
if (status.provider === 'disabled') return 'sky';
|
||||
return 'mint';
|
||||
});
|
||||
|
||||
const stateLabel = $derived.by(() => {
|
||||
if (!status) return t('settings.release.statusUnknown');
|
||||
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
|
||||
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
|
||||
if (status.update_available) return t('settings.release.statusUpdate');
|
||||
if (status.latest) return t('settings.release.statusUpToDate');
|
||||
return t('settings.release.statusUnknown');
|
||||
});
|
||||
|
||||
// Map backend error taxonomy → localized text. Falls back to the raw code
|
||||
// only when the key is missing (so a new server code surfaces something).
|
||||
function localizedError(code: string | null): string {
|
||||
if (!code) return '';
|
||||
const key = `settings.release.error.${code}`;
|
||||
const localized = t(key);
|
||||
// `t` falls back to the key itself when missing — detect by exact match.
|
||||
return localized === key ? code : localized;
|
||||
}
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return t('settings.release.never');
|
||||
const then = Date.parse(iso);
|
||||
if (!Number.isFinite(then)) return t('settings.release.never');
|
||||
const diff = Date.now() - then;
|
||||
const min = Math.round(diff / 60_000);
|
||||
if (min < 1) return t('settings.release.justNow');
|
||||
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
|
||||
const h = Math.round(min / 60);
|
||||
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
|
||||
const d = Math.round(h / 24);
|
||||
return t('settings.release.daysAgo').replace('{n}', String(d));
|
||||
}
|
||||
|
||||
function setProvider(kind: ReleaseProviderKind): void {
|
||||
providerKind = kind;
|
||||
}
|
||||
|
||||
function onIntervalInput(e: Event): void {
|
||||
// The native input emits string values; we keep the contract by
|
||||
// re-coercing to string before assigning to the bindable prop.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
|
||||
}
|
||||
|
||||
async function checkNow(): Promise<void> {
|
||||
checking = true;
|
||||
try {
|
||||
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
|
||||
releaseStatusCache.set(next);
|
||||
snackSuccess(t('settings.release.checkDone'));
|
||||
} catch (err: unknown) {
|
||||
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(): Promise<void> {
|
||||
testing = true;
|
||||
testResult = null;
|
||||
try {
|
||||
testResult = await api<ReleaseTestResult>('/settings/release/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
provider_kind: providerKind,
|
||||
provider_url: providerUrl,
|
||||
provider_repo: providerRepo,
|
||||
include_prereleases: prereleaseChecked,
|
||||
}),
|
||||
});
|
||||
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
|
||||
else snackError(t('settings.release.testFailed'));
|
||||
} catch (err: unknown) {
|
||||
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rel glass" id="release">
|
||||
<header class="rel-head">
|
||||
<div class="rel-eyebrow">
|
||||
<MdiIcon name="mdiUpdate" size={12} />
|
||||
<span>{t('settings.release.eyebrow')}</span>
|
||||
</div>
|
||||
<h3 class="rel-title">{t('settings.release.headline')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="rel-body">
|
||||
<!-- 01 Provider — native radios for free keyboard a11y. -->
|
||||
<div class="row">
|
||||
<div class="row-label">
|
||||
<span class="row-num">01</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.provider')}
|
||||
<Hint text={t('settings.release.providerHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
|
||||
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="gitea"
|
||||
checked={providerKind === 'gitea'}
|
||||
onchange={() => setProvider('gitea')}
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
|
||||
</label>
|
||||
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="github"
|
||||
disabled
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
|
||||
</label>
|
||||
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="release-provider"
|
||||
value="disabled"
|
||||
checked={providerKind === 'disabled'}
|
||||
onchange={() => setProvider('disabled')}
|
||||
class="seg-radio"
|
||||
/>
|
||||
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 Repository -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">02</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.repository')}
|
||||
<Hint text={t('settings.release.repositoryHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control repo-grid">
|
||||
<input
|
||||
bind:value={providerUrl}
|
||||
placeholder="https://git.example.com"
|
||||
class="text-input"
|
||||
type="url"
|
||||
spellcheck="false"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<input
|
||||
bind:value={providerRepo}
|
||||
placeholder="owner/repo"
|
||||
class="text-input mono"
|
||||
spellcheck="false"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 Options — slider toggle for include-prereleases. -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">03</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.options')}
|
||||
<Hint text={t('settings.release.prereleasesHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
class:toggle-disabled={isDisabled}
|
||||
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
|
||||
aria-pressed={prereleaseChecked}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 Check interval -->
|
||||
<div class="row" class:row-dim={isDisabled}>
|
||||
<div class="row-label">
|
||||
<span class="row-num">04</span>
|
||||
<span class="row-name">
|
||||
{t('settings.release.interval')}
|
||||
<Hint text={t('settings.release.intervalHint')} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-control interval">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={checkIntervalHours}
|
||||
oninput={onIntervalInput}
|
||||
class="text-input num"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span class="unit">{t('settings.release.hoursUnit')}</span>
|
||||
<span class="footnote">{t('settings.release.intervalRange')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State strip -->
|
||||
<footer class="strip" data-tone={stateTone}>
|
||||
<div class="strip-left">
|
||||
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
|
||||
<div class="strip-text">
|
||||
<div class="strip-state">{stateLabel}</div>
|
||||
<div class="strip-meta">
|
||||
<span class="versions">
|
||||
<span class="v-current">v{status?.current ?? '—'}</span>
|
||||
{#if status?.latest && status.latest !== status.current}
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
<span
|
||||
class="v-latest"
|
||||
class:v-latest-update={status.update_available}
|
||||
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="sep" aria-hidden="true">·</span>
|
||||
<span class="checked">
|
||||
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
|
||||
<div class="strip-error">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if testResult && !testResult.ok}
|
||||
<div class="strip-error">
|
||||
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
|
||||
{localizedError(testResult.error)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if testResult && testResult.ok && testResult.info}
|
||||
<div class="strip-test-ok">
|
||||
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
|
||||
<span class="mono">v{testResult.info.version}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-actions">
|
||||
{#if status?.update_available && status.latest_url}
|
||||
<a
|
||||
class="strip-btn strip-btn-cta"
|
||||
href={status.latest_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-btn"
|
||||
onclick={testProvider}
|
||||
disabled={testing || isDisabled || !providerRepo}
|
||||
>
|
||||
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
|
||||
<span>{t('settings.release.testConnection')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="strip-btn strip-btn-primary"
|
||||
onclick={checkNow}
|
||||
disabled={checking || isDisabled}
|
||||
>
|
||||
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
|
||||
<span>{t('settings.release.checkNow')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.rel {
|
||||
padding: 1.5rem 1.6rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rel-head { position: relative; z-index: 1; }
|
||||
.rel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.rel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.rel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 1.4rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||
.row-dim { opacity: 0.55; }
|
||||
.row-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.row-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.row-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.row-control { min-width: 0; }
|
||||
|
||||
/* Segmented provider control — uses real radios so arrow-key + tab
|
||||
navigation just work via the browser. */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.seg-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.seg-radio {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
}
|
||||
.seg-radio:focus-visible + .seg-content {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.seg-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
transition: background 0.18s, color 0.18s;
|
||||
}
|
||||
.seg-item:hover:not(.seg-soon) .seg-content {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-glass);
|
||||
}
|
||||
.seg-active .seg-content {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-input-bg);
|
||||
box-shadow: 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
.seg-soon { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* Text fields */
|
||||
.repo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-input-bg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.text-input.mono { font-family: var(--font-mono); }
|
||||
.text-input.num { max-width: 6rem; text-align: right; }
|
||||
.text-input:focus {
|
||||
outline: 0;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow);
|
||||
}
|
||||
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
/* Interval */
|
||||
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.unit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.footnote {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||
}
|
||||
.toggle-on .toggle-thumb {
|
||||
background: white;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.toggle-label-text { font-size: 0.82rem; }
|
||||
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* State strip */
|
||||
.strip {
|
||||
margin: 0 -1.6rem;
|
||||
padding: 1rem 1.6rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
|
||||
transparent
|
||||
);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
.strip[data-tone="citrus"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 10%,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
|
||||
transparent 90%
|
||||
);
|
||||
animation: aurora-shimmer 4s linear infinite;
|
||||
}
|
||||
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
|
||||
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
|
||||
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
|
||||
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
|
||||
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.strip-state {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.strip-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||
.arrow { color: var(--color-muted-foreground); }
|
||||
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
|
||||
.sep { opacity: 0.5; }
|
||||
.rel-time { color: var(--color-foreground); }
|
||||
.strip-error {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-coral, #d27a7a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.strip-test-ok {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-mint, #6fcfa6);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.strip-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s, border-color 0.18s, transform 0.18s;
|
||||
}
|
||||
.strip-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.strip-btn-primary {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
|
||||
}
|
||||
/* The CTA — high-visibility when an update is available. */
|
||||
.strip-btn-cta {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
|
||||
}
|
||||
.strip-btn-cta:hover {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
|
||||
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
|
||||
}
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
@keyframes aurora-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.strip[data-tone="citrus"]::before { animation: none; }
|
||||
.strip-btn { transition: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
.row-label { padding-top: 0; }
|
||||
.repo-grid { grid-template-columns: 1fr; }
|
||||
.strip { flex-direction: column; align-items: stretch; }
|
||||
.strip-actions { justify-content: stretch; }
|
||||
.strip-btn { flex: 1; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
|
||||
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||
|
||||
@@ -81,6 +82,19 @@
|
||||
tone: SEVERITY_TONE[lvl] ?? 'mint',
|
||||
});
|
||||
|
||||
const rs = releaseStatusCache.value;
|
||||
if (rs) {
|
||||
if (rs.provider === 'disabled') {
|
||||
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
|
||||
} else if (rs.error && rs.error !== 'provider_changed') {
|
||||
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
|
||||
} else if (rs.update_available && rs.latest) {
|
||||
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
|
||||
} else if (rs.latest) {
|
||||
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, fetchAuth } from '$lib/api';
|
||||
import { api, fetchAuth , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
@@ -97,9 +97,10 @@
|
||||
scheduledSettings = settings;
|
||||
backupFiles = files;
|
||||
pending = p;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
const m = errMsg(err);
|
||||
error = m;
|
||||
snackError(m);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
@@ -110,7 +111,7 @@
|
||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||
snackSuccess(t('backup.pendingCancelled'));
|
||||
pending = null;
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function applyAndRestart(): Promise<void> {
|
||||
@@ -131,9 +132,9 @@
|
||||
if (attempts < 120) setTimeout(poll, 1000);
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
restartingOverlay = false;
|
||||
snackError(err.message);
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +145,8 @@
|
||||
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||
snackSuccess(t('backup.manualCreated'));
|
||||
await refreshFiles();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
creatingBackup = false;
|
||||
}
|
||||
@@ -178,8 +179,8 @@
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
snackSuccess(t('backup.exportSuccess'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
exporting = false;
|
||||
}
|
||||
@@ -202,8 +203,8 @@
|
||||
formData.append('file', importFile);
|
||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||
validationResult = await res.json();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
validating = false;
|
||||
}
|
||||
@@ -230,8 +231,8 @@
|
||||
snackSuccess(t('backup.restorePrepared'));
|
||||
postRestoreModalOpen = true;
|
||||
importFile = null;
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
@@ -246,8 +247,8 @@
|
||||
body: JSON.stringify(scheduledSettings),
|
||||
});
|
||||
snackSuccess(t('backup.scheduleSaved'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
savingSchedule = false;
|
||||
}
|
||||
@@ -258,8 +259,8 @@
|
||||
loadingFiles = true;
|
||||
try {
|
||||
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
loadingFiles = false;
|
||||
}
|
||||
@@ -275,8 +276,8 @@
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +287,8 @@
|
||||
snackSuccess(t('backup.fileDeleted'));
|
||||
confirmDeleteFile = '';
|
||||
await refreshFiles();
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { setup } from '$lib/auth.svelte';
|
||||
import { errMsg } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
@@ -25,7 +26,7 @@
|
||||
try {
|
||||
await setup(username, password);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
|
||||
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
|
||||
submitting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { page } from '$app/state';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t, getLocale } from '$lib/i18n';
|
||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||
@@ -15,6 +15,7 @@
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import { chatActionItems } from '$lib/grid-items';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { highlightFromUrl } from '$lib/highlight';
|
||||
@@ -25,7 +26,7 @@
|
||||
import ReceiverSection from './ReceiverSection.svelte';
|
||||
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||
|
||||
// ── Helpers ──
|
||||
// ──── Helpers ────
|
||||
|
||||
function getBotName(target: NotificationTarget): string | null {
|
||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||
@@ -73,7 +74,7 @@
|
||||
return recv.receiver_key || '?';
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
// ──── Constants ────
|
||||
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||
type TargetType = typeof ALL_TYPES[number];
|
||||
@@ -94,7 +95,54 @@
|
||||
label: tt.charAt(0).toUpperCase() + tt.slice(1),
|
||||
})));
|
||||
|
||||
// ── Derived state ──
|
||||
function targetTiles(target: NotificationTarget): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// from multiple types appear side-by-side. The receivers count is
|
||||
// already shown inside the `target-summary` button, so we don't repeat
|
||||
// it as a tile.
|
||||
tiles.push({
|
||||
icon: TYPE_ICONS[target.type] || 'mdiTarget',
|
||||
label: target.type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const botName = getBotName(target);
|
||||
if (botName) {
|
||||
tiles.push({
|
||||
icon: 'mdiRobot',
|
||||
label: botName,
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram · @bot · Family chat" without expanding.
|
||||
const cfg = (target.config || {}) as Record<string, any>;
|
||||
if (target.type === 'telegram' && cfg.chat_id) {
|
||||
tiles.push({
|
||||
icon: 'mdiChat',
|
||||
label: String(cfg.chat_id),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook target — show host
|
||||
if (target.type === 'webhook' && cfg.url) {
|
||||
let host = String(cfg.url);
|
||||
try { host = new URL(host).host; } catch { /* keep raw */ }
|
||||
tiles.push({
|
||||
icon: 'mdiLinkVariant',
|
||||
label: host,
|
||||
hint: String(cfg.url),
|
||||
href: String(cfg.url),
|
||||
tone: 'orchid',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
// ──── Derived state ────
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
@@ -110,7 +158,7 @@
|
||||
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
||||
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
||||
|
||||
// ── Target form state ──
|
||||
// ──── Target form state ────
|
||||
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -156,7 +204,7 @@
|
||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ── Receiver inline form state ──
|
||||
// ──── Receiver inline form state ────
|
||||
|
||||
let addingReceiverForTarget = $state<number | null>(null);
|
||||
let receiverForm = $state<Record<string, any>>({});
|
||||
@@ -180,7 +228,7 @@
|
||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||
}
|
||||
|
||||
// ── Effects ──
|
||||
// ──── Effects ────
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
$effect(() => {
|
||||
@@ -191,11 +239,11 @@
|
||||
addingReceiverForTarget = null;
|
||||
});
|
||||
|
||||
// ── Data loading ──
|
||||
// ──── Data loading ────
|
||||
|
||||
onMount(load);
|
||||
|
||||
// ── Bot grouping ──
|
||||
// ──── Bot grouping ────
|
||||
|
||||
type TargetGroup = {
|
||||
key: string;
|
||||
@@ -307,8 +355,8 @@
|
||||
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
||||
]);
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('common.loadError');
|
||||
} catch (err: unknown) {
|
||||
loadError = errMsg(err, t('common.loadError'));
|
||||
snackError(loadError);
|
||||
} finally {
|
||||
loaded = true;
|
||||
@@ -334,7 +382,7 @@
|
||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||
}
|
||||
|
||||
// ── Target CRUD ──
|
||||
// ──── Target CRUD ────
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
@@ -427,9 +475,10 @@
|
||||
editing = null;
|
||||
await load();
|
||||
snackSuccess(t('snack.targetSaved'));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
const m = errMsg(err);
|
||||
error = m;
|
||||
snackError(m);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
@@ -440,7 +489,7 @@
|
||||
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
@@ -449,15 +498,16 @@
|
||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetDeleted'));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message;
|
||||
snackError(err.message);
|
||||
const m = errMsg(err);
|
||||
error = m;
|
||||
snackError(m);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receiver CRUD ──
|
||||
// ──── Receiver CRUD ────
|
||||
|
||||
async function openReceiverForm(targetId: number, targetType: string) {
|
||||
// Force a remount of any picker palette when the same target is reopened
|
||||
@@ -527,8 +577,8 @@
|
||||
addingReceiverForTarget = null;
|
||||
await load();
|
||||
snackSuccess(t('targets.receiverAdded'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
} finally {
|
||||
receiverSubmitting = false;
|
||||
}
|
||||
@@ -542,7 +592,7 @@
|
||||
});
|
||||
await load();
|
||||
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function removeReceiver(targetId: number, receiverId: number) {
|
||||
@@ -550,7 +600,7 @@
|
||||
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('targets.receiverDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function toggleBroadcastChild(targetId: number, childId: number) {
|
||||
@@ -565,7 +615,7 @@
|
||||
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
||||
});
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
}
|
||||
|
||||
async function testReceiver(targetId: number, receiverId: number) {
|
||||
@@ -574,7 +624,7 @@
|
||||
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
||||
}
|
||||
</script>
|
||||
@@ -660,7 +710,7 @@
|
||||
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
|
||||
<Card hover entityId={target.id}>
|
||||
<!-- Target header (clickable to toggle receiver visibility) -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="target-summary"
|
||||
@@ -682,6 +732,7 @@
|
||||
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<MetaStrip tiles={targetTiles(target)} />
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||
@@ -765,7 +816,7 @@
|
||||
}
|
||||
|
||||
.target-summary {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -780,6 +831,12 @@
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.target-summary {
|
||||
flex: 0 1 auto;
|
||||
max-width: 32rem;
|
||||
}
|
||||
}
|
||||
.target-summary:hover {
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
@@ -28,6 +28,7 @@
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
import type { TemplateConfig } from '$lib/types';
|
||||
|
||||
@@ -260,7 +261,7 @@
|
||||
capabilitiesCache.fetch(),
|
||||
supportedLocalesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@
|
||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.templateSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,8 +404,8 @@
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} catch (err: unknown) {
|
||||
snackError(errMsg(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,16 +427,55 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
tiles.push({
|
||||
icon: 'mdiServer',
|
||||
label: config.provider_type,
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
const slotCount = Object.keys(config.slots || {}).length;
|
||||
tiles.push({
|
||||
icon: 'mdiViewGridOutline',
|
||||
value: String(slotCount),
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
}
|
||||
if (locales.size > 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiTranslate',
|
||||
value: String(locales.size),
|
||||
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
|
||||
hint: [...locales].sort().join(', '),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
if (config.user_id === 0) {
|
||||
tiles.push({
|
||||
icon: 'mdiShieldStarOutline',
|
||||
label: t('common.system'),
|
||||
tone: 'orchid',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
@@ -586,7 +626,7 @@
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
@@ -627,24 +667,25 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<MetaStrip tiles={templateConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
@@ -26,6 +26,7 @@
|
||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
@@ -157,8 +158,8 @@
|
||||
error: res?.error || '',
|
||||
locale,
|
||||
};
|
||||
} catch (err: any) {
|
||||
previewModal = { slotName, rendered: '', error: err.message, locale };
|
||||
} catch (err: unknown) {
|
||||
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
@@ -216,7 +217,7 @@
|
||||
});
|
||||
async function load() {
|
||||
try { await trackingConfigsCache.fetch(true); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||||
}
|
||||
|
||||
@@ -238,6 +239,38 @@
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
|
||||
function trackingConfigTiles(config: Record<string, any>): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const desc = getDescriptor(config.provider_type);
|
||||
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
|
||||
tiles.push({
|
||||
icon: 'mdiPulse',
|
||||
value: String(events.length),
|
||||
label: t('trackingConfig.eventTracking'),
|
||||
hint: events.map(f => t(f.label)).join(', ') || undefined,
|
||||
tone: events.length > 0 ? 'lavender' : 'default',
|
||||
});
|
||||
if (config.periodic_enabled) {
|
||||
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
|
||||
}
|
||||
if (config.scheduled_enabled) {
|
||||
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
|
||||
}
|
||||
if (config.memory_enabled) {
|
||||
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
|
||||
}
|
||||
if (config.quiet_hours_start && config.quiet_hours_end) {
|
||||
tiles.push({
|
||||
icon: 'mdiWeatherNight',
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
hint: t('trackingConfig.quietHoursStart'),
|
||||
tone: 'citrus',
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
@@ -252,7 +285,7 @@
|
||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.trackingConfigSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
}
|
||||
|
||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||
@@ -261,10 +294,10 @@
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||
catch (err: any) {
|
||||
catch (err: unknown) {
|
||||
const bb = getBlockedBy(err);
|
||||
if (bb) { blockedBy = bb; return; }
|
||||
error = err.message; snackError(err.message);
|
||||
const m = errMsg(err); error = m; snackError(m);
|
||||
}
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
@@ -448,25 +481,26 @@
|
||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
<div class="list-stack stagger-children">
|
||||
{#each configs as config}
|
||||
{@const desc = getDescriptor(config.provider_type)}
|
||||
<Card hover entityId={config.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
||||
<p class="font-medium truncate">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MetaStrip tiles={trackingConfigTiles(config)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -1,200 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { User } from '$lib/types';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<User[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
let resetUsername = $state('');
|
||||
let resetPassword = $state('');
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
// Admin edit username/role
|
||||
let editUserId = $state<number | null>(null);
|
||||
let editUsername = $state('');
|
||||
let editRole = $state('user');
|
||||
let editMsg = $state('');
|
||||
let editSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
function openEditUser(user: any) {
|
||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||
}
|
||||
async function saveUserEdit(e: SubmitEvent) {
|
||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||
try {
|
||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||
editMsg = t('snack.userUpdated');
|
||||
editSuccess = true;
|
||||
snackSuccess(editMsg);
|
||||
await load();
|
||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||
resetMsg = t('common.passwordChanged');
|
||||
resetSuccess = true;
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('users.title')}
|
||||
emphasis={t('users.titleEmphasis')}
|
||||
description={t('users.description')}
|
||||
crumb={t('crumbs.systemAccess')}
|
||||
count={users.length}
|
||||
countLabel={t('users.countLabel')}
|
||||
>
|
||||
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<ErrorBanner message={error} />{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
||||
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit">{t('users.create')}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if users.length === 0}
|
||||
<Card>
|
||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Admin reset password modal -->
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if resetMsg}
|
||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Admin edit username/role modal -->
|
||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||
<div>
|
||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="edit-username" bind:value={editUsername} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="edit-role" bind:value={editRole}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editMsg}
|
||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, parseDate , errMsg} from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
import type { User } from '$lib/types';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<User[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
let resetUsername = $state('');
|
||||
let resetPassword = $state('');
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
// Admin edit username/role
|
||||
let editUserId = $state<number | null>(null);
|
||||
let editUsername = $state('');
|
||||
let editRole = $state('user');
|
||||
let editMsg = $state('');
|
||||
let editSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
}
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
function openEditUser(user: any) {
|
||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||
}
|
||||
async function saveUserEdit(e: SubmitEvent) {
|
||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||
try {
|
||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||
editMsg = t('snack.userUpdated');
|
||||
editSuccess = true;
|
||||
snackSuccess(editMsg);
|
||||
await load();
|
||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||
resetMsg = t('common.passwordChanged');
|
||||
resetSuccess = true;
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
|
||||
}
|
||||
|
||||
function userTiles(user: User): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const isAdmin = user.role === 'admin';
|
||||
tiles.push({
|
||||
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
||||
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
||||
tone: isAdmin ? 'orchid' : 'sky',
|
||||
});
|
||||
tiles.push({
|
||||
icon: 'mdiCalendarOutline',
|
||||
label: parseDate(user.created_at).toLocaleDateString(),
|
||||
hint: t('users.joined'),
|
||||
tone: 'lavender',
|
||||
mono: true,
|
||||
});
|
||||
if (user.id === auth.user?.id) {
|
||||
tiles.push({
|
||||
icon: 'mdiAccountStar',
|
||||
label: t('users.you', 'you'),
|
||||
tone: 'mint',
|
||||
});
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('users.title')}
|
||||
emphasis={t('users.titleEmphasis')}
|
||||
description={t('users.description')}
|
||||
crumb={t('crumbs.systemAccess')}
|
||||
count={users.length}
|
||||
countLabel={t('users.countLabel')}
|
||||
>
|
||||
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<ErrorBanner message={error} />{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
||||
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit">{t('users.create')}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if users.length === 0}
|
||||
<Card>
|
||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="list-stack stagger-children">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="list-row">
|
||||
<div class="list-row__identity">
|
||||
<p class="font-medium truncate">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<MetaStrip tiles={userTiles(user)} />
|
||||
<div class="list-row__actions">
|
||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Admin reset password modal -->
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if resetMsg}
|
||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Admin edit username/role modal -->
|
||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||
<div>
|
||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="edit-username" bind:value={editUsername} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="edit-role" bind:value={editRole}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editMsg}
|
||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.7.2"
|
||||
version = "0.8.1"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -65,6 +65,18 @@ class EventType(str, Enum):
|
||||
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
||||
UPS_OVERLOAD = "ups_overload"
|
||||
|
||||
# Home Assistant events
|
||||
HA_STATE_CHANGED = "ha_state_changed"
|
||||
HA_AUTOMATION_TRIGGERED = "ha_automation_triggered"
|
||||
HA_SERVICE_CALLED = "ha_service_called"
|
||||
HA_EVENT_FIRED = "ha_event_fired"
|
||||
|
||||
# Bridge self-monitoring events — emitted by the bridge itself when
|
||||
# internal failures cross configured thresholds.
|
||||
BRIDGE_SELF_POLL_FAILURES = "bridge_self_poll_failures"
|
||||
BRIDGE_SELF_DEFERRED_BACKLOG = "bridge_self_deferred_backlog"
|
||||
BRIDGE_SELF_TARGET_FAILURES = "bridge_self_target_failures"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceEvent:
|
||||
|
||||
@@ -107,6 +107,12 @@ class NotificationDispatcher:
|
||||
# Optional shared session owned by the caller; when supplied we reuse
|
||||
# its connection pool instead of opening a fresh per-dispatch session.
|
||||
self._shared_session = session
|
||||
# Per-dispatch render cache, keyed by locale. Populated by
|
||||
# ``_send_to_target`` and consumed inside ``_message_for_receiver``
|
||||
# so a 100-receiver fan-out renders each unique locale once.
|
||||
# Initialized to empty so handlers called outside the normal
|
||||
# dispatch path (tests) still see a valid dict.
|
||||
self._render_cache: dict[str, str] = {}
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
||||
@@ -198,20 +204,49 @@ class NotificationDispatcher:
|
||||
def _message_for_receiver(
|
||||
self, receiver: Receiver, default_message: str,
|
||||
event: ServiceEvent, target: TargetConfig,
|
||||
cache: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
if receiver.locale and receiver.locale != target.locale:
|
||||
return self._render_message(event, target, receiver.locale)
|
||||
return default_message
|
||||
"""Render message respecting receiver locale, with optional cache.
|
||||
|
||||
The ``cache`` dict (typically created in ``_send_to_target`` and
|
||||
threaded through the per-channel ``_send_*`` handlers) memoizes
|
||||
per-locale renders so a 100-receiver fan-out with two locales
|
||||
renders twice instead of one hundred times.
|
||||
"""
|
||||
loc = receiver.locale or target.locale
|
||||
if loc == target.locale:
|
||||
return default_message
|
||||
if cache is not None:
|
||||
cached = cache.get(loc)
|
||||
if cached is not None:
|
||||
return cached
|
||||
rendered = self._render_message(event, target, loc)
|
||||
cache[loc] = rendered
|
||||
return rendered
|
||||
return self._render_message(event, target, loc)
|
||||
|
||||
async def _send_to_target(
|
||||
self, event: ServiceEvent, target: TargetConfig
|
||||
) -> dict[str, Any]:
|
||||
"""Dispatch to a single target via the registered handler."""
|
||||
"""Dispatch to a single target via the registered handler.
|
||||
|
||||
Builds a per-locale render cache once and threads it through the
|
||||
send handler. The cache is keyed by receiver locale; the default
|
||||
locale's render lives in ``default_message`` and is short-circuited
|
||||
before any cache lookup.
|
||||
"""
|
||||
default_message = self._render_message(event, target, target.locale)
|
||||
send_method = _PROVIDER_HANDLERS.get(target.type)
|
||||
if send_method is None:
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
return await send_method(self, target, default_message, event)
|
||||
# Stash the cache on the dispatcher instance for the duration of
|
||||
# this dispatch — handlers pick it up via _message_for_receiver.
|
||||
# Avoids changing every _send_* signature.
|
||||
self._render_cache: dict[str, str] = {}
|
||||
try:
|
||||
return await send_method(self, target, default_message, event)
|
||||
finally:
|
||||
self._render_cache = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Asset preload (Telegram-specific)
|
||||
@@ -352,7 +387,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
||||
return {"success": False, "error": "Invalid telegram receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
text_result = await client.send_message(
|
||||
chat_id=receiver.chat_id,
|
||||
text=message,
|
||||
@@ -407,7 +442,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||
return {"success": False, "error": "Invalid webhook receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
payload = {
|
||||
"message": message,
|
||||
"event_type": event.event_type.value,
|
||||
@@ -450,7 +485,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
||||
return {"success": False, "error": "Invalid email receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
# body_html=None lets EmailClient build a safely-escaped HTML
|
||||
# alternative from body_text instead of trusting user content.
|
||||
return await email_client.send(
|
||||
@@ -479,7 +514,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||
return {"success": False, "error": "Invalid discord receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
return await client.send(receiver.webhook_url, message, username=username)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
@@ -501,7 +536,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||
return {"success": False, "error": "Invalid slack receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
return await client.send(receiver.webhook_url, message, username=username)
|
||||
|
||||
results = await self._fan_out(target.receivers, send_one)
|
||||
@@ -530,7 +565,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||
return {"success": False, "error": "Invalid ntfy receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
return await client.send(
|
||||
server_url, receiver.topic, message,
|
||||
title=title, priority=receiver.priority, auth_token=auth_token,
|
||||
@@ -563,7 +598,7 @@ class NotificationDispatcher:
|
||||
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||
return {"success": False, "error": "Invalid matrix receiver"}
|
||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||
# body_html is the same plain text — Matrix accepts the
|
||||
# raw message as both ``body`` and ``formatted_body``.
|
||||
# If templates emit HTML in the future, generate a
|
||||
|
||||
@@ -222,21 +222,48 @@ class TelegramClient:
|
||||
"""SSRF-guarded GET that returns ``(data, error)``.
|
||||
|
||||
Validates the URL via ``avalidate_outbound_url`` before any HTTP
|
||||
traffic. Errors are returned (not raised) and stripped of any
|
||||
embedded secrets before they propagate to the operator-visible
|
||||
result dict.
|
||||
traffic. Redirects are walked manually so each ``Location`` is
|
||||
re-validated — without this an attacker-controlled origin could
|
||||
302 to a private-IP target after the initial guard passed.
|
||||
Errors are returned (not raised) and stripped of any embedded
|
||||
secrets before they propagate to the operator-visible result
|
||||
dict.
|
||||
"""
|
||||
max_redirects = 3
|
||||
current_url = url
|
||||
try:
|
||||
await avalidate_outbound_url(url)
|
||||
await avalidate_outbound_url(current_url)
|
||||
except UnsafeURLError as err:
|
||||
return None, f"Unsafe URL: {redact_exc(err)}"
|
||||
try:
|
||||
async with self._session.get(
|
||||
url, headers=headers or {}, timeout=_DOWNLOAD_TIMEOUT,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return None, f"HTTP {resp.status}"
|
||||
return await resp.read(), None
|
||||
for _ in range(max_redirects + 1):
|
||||
async with self._session.get(
|
||||
current_url,
|
||||
headers=headers or {},
|
||||
timeout=_DOWNLOAD_TIMEOUT,
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
if resp.status in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("Location")
|
||||
if not loc:
|
||||
return None, f"HTTP {resp.status} without Location header"
|
||||
# ``resp.url`` is a yarl.URL; ``.join`` resolves
|
||||
# relative redirects (``/foo/bar``) against it.
|
||||
from yarl import URL as _URL
|
||||
try:
|
||||
next_url = str(resp.url.join(_URL(loc)))
|
||||
except (ValueError, TypeError):
|
||||
return None, "Malformed redirect Location"
|
||||
try:
|
||||
await avalidate_outbound_url(next_url)
|
||||
except UnsafeURLError as err:
|
||||
return None, f"Unsafe redirect: {redact_exc(err)}"
|
||||
current_url = next_url
|
||||
continue
|
||||
if resp.status != 200:
|
||||
return None, f"HTTP {resp.status}"
|
||||
return await resp.read(), None
|
||||
return None, f"Too many redirects (>{max_redirects})"
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||
return None, redact_exc(err)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
@@ -21,6 +21,14 @@ class ServiceProviderType(str, Enum):
|
||||
NUT = "nut"
|
||||
GOOGLE_PHOTOS = "google_photos"
|
||||
WEBHOOK = "webhook"
|
||||
HOME_ASSISTANT = "home_assistant"
|
||||
BRIDGE_SELF = "bridge_self"
|
||||
|
||||
|
||||
# Callback signature for push-style providers: a coroutine that accepts a
|
||||
# parsed ServiceEvent and is expected to enqueue it for dispatch. Returning
|
||||
# None keeps the contract narrow — error handling stays inside the callback.
|
||||
EventEmitCallback = Callable[["ServiceEvent"], Awaitable[None]]
|
||||
|
||||
|
||||
class ServiceProvider(ABC):
|
||||
@@ -28,10 +36,27 @@ class ServiceProvider(ABC):
|
||||
|
||||
A service provider connects to an external service (e.g., Immich photo server)
|
||||
and can poll for changes, producing generic ServiceEvent objects.
|
||||
|
||||
Two ingest modes coexist on this base class:
|
||||
|
||||
* Polling providers (Immich, NUT, Google Photos, Scheduler) implement
|
||||
:meth:`poll` and leave :attr:`supports_subscription` False.
|
||||
* Webhook providers (Gitea, Planka, generic Webhook) no-op :meth:`poll`
|
||||
and receive events out-of-band via ``api/webhooks.py``.
|
||||
* Subscription providers (Home Assistant) flip
|
||||
:attr:`supports_subscription` to True and implement :meth:`subscribe`
|
||||
to run a long-lived task that pushes events through an
|
||||
``emit`` callback. They typically no-op :meth:`poll`.
|
||||
"""
|
||||
|
||||
provider_type: ServiceProviderType
|
||||
|
||||
# When True, the lifecycle layer (server-side subscription manager) starts
|
||||
# a long-running task that calls :meth:`subscribe` instead of registering
|
||||
# this provider with the polling scheduler. Default False keeps the
|
||||
# legacy poll/webhook flow intact for every existing provider.
|
||||
supports_subscription: bool = False
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the service and verify connectivity.
|
||||
@@ -59,6 +84,27 @@ class ServiceProvider(ABC):
|
||||
Tuple of (list of events detected, updated state dict).
|
||||
"""
|
||||
|
||||
async def subscribe(self, emit: EventEmitCallback) -> None:
|
||||
"""Run a long-lived subscription that calls ``emit`` for each event.
|
||||
|
||||
Override on providers with :attr:`supports_subscription` = True. The
|
||||
implementation is expected to:
|
||||
|
||||
* Loop until cancelled (the subscription manager uses
|
||||
:func:`asyncio.Task.cancel` on shutdown).
|
||||
* Handle its own reconnect with exponential backoff — never propagate
|
||||
transient network errors to the caller.
|
||||
* Pass parsed :class:`ServiceEvent` instances to ``emit`` for
|
||||
enqueueing/dispatch. The callback is responsible for routing.
|
||||
|
||||
The default implementation raises :class:`NotImplementedError` so
|
||||
accidental wiring of a polling provider into the subscription manager
|
||||
fails loudly rather than silently doing nothing.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support subscription-based ingest"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
"""Return the template variables this provider makes available."""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Bridge self-monitoring service provider.
|
||||
|
||||
Unlike external providers (Immich, Gitea, NUT, ...), the ``bridge_self``
|
||||
provider does not connect to any remote service. Its sole purpose is to
|
||||
give operators a configurable surface (thresholds + notification slots
|
||||
+ trackers + targets) for events that the bridge itself emits when its
|
||||
internal subsystems fail.
|
||||
|
||||
Three failure conditions are surfaced as :class:`ServiceEvent` instances
|
||||
through the same dispatch pipeline that all other providers use:
|
||||
|
||||
* ``bridge_self_poll_failures`` — N consecutive poll failures for
|
||||
any tracker exceed the configured threshold.
|
||||
* ``bridge_self_deferred_backlog`` — pending ``deferred_dispatch`` row
|
||||
count crosses the configured threshold.
|
||||
* ``bridge_self_target_failures`` — N consecutive 5xx / network failures
|
||||
for a single notification target.
|
||||
|
||||
Events are constructed by ``services/bridge_self.py`` on the server side
|
||||
(it owns DB access for looking up the bridge_self provider per user)
|
||||
and then fed into ``dispatch_provider_event`` like any other event.
|
||||
"""
|
||||
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
|
||||
from .event_parser import build_event
|
||||
from .provider import BRIDGE_SELF_VARIABLES, BridgeSelfServiceProvider
|
||||
|
||||
# Register variables so the validator and template-vars API see them.
|
||||
registry.register_provider_variables(
|
||||
ServiceProviderType.BRIDGE_SELF, BRIDGE_SELF_VARIABLES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BRIDGE_SELF_VARIABLES",
|
||||
"BridgeSelfServiceProvider",
|
||||
"build_event",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Bridge self-monitoring event parser.
|
||||
|
||||
The bridge generates these events from internal subsystems (watcher,
|
||||
scheduler, dispatcher) — the parser turns a flat payload dict into the
|
||||
generic :class:`ServiceEvent` shape that the rest of the dispatch
|
||||
pipeline expects.
|
||||
|
||||
Payload shape::
|
||||
|
||||
{
|
||||
"failure_type": "poll_failures" | "deferred_backlog" | "target_failures",
|
||||
"subject_id": int, # tracker_id, target_id, or 0
|
||||
"subject_name": str,
|
||||
"count": int, # consecutive failures or pending count
|
||||
"threshold": int,
|
||||
"last_error": str, # may be empty
|
||||
"details": dict[str, Any], # extra context
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
|
||||
|
||||
# Defensive cap on the persisted error message; very long tracebacks would
|
||||
# bloat the EventLog details JSON column otherwise.
|
||||
_MAX_ERROR_LEN = 1000
|
||||
|
||||
|
||||
_FAILURE_TYPE_TO_EVENT: dict[str, EventType] = {
|
||||
"poll_failures": EventType.BRIDGE_SELF_POLL_FAILURES,
|
||||
"deferred_backlog": EventType.BRIDGE_SELF_DEFERRED_BACKLOG,
|
||||
"target_failures": EventType.BRIDGE_SELF_TARGET_FAILURES,
|
||||
}
|
||||
|
||||
|
||||
def build_event(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
provider_name: str = "Bridge Self-Monitoring",
|
||||
timestamp: datetime | None = None,
|
||||
) -> ServiceEvent | None:
|
||||
"""Convert a self-monitoring payload dict into a ServiceEvent.
|
||||
|
||||
Returns None for malformed payloads (unknown failure_type or missing
|
||||
keys) — the caller drops without raising so a misbehaving emitter
|
||||
can never tip over the dispatch pipeline.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
failure_type = payload.get("failure_type")
|
||||
event_type = _FAILURE_TYPE_TO_EVENT.get(str(failure_type) if failure_type else "")
|
||||
if event_type is None:
|
||||
return None
|
||||
|
||||
subject_id = int(payload.get("subject_id") or 0)
|
||||
subject_name = str(payload.get("subject_name") or "")
|
||||
count = int(payload.get("count") or 0)
|
||||
threshold = int(payload.get("threshold") or 0)
|
||||
last_error = str(payload.get("last_error") or "")[:_MAX_ERROR_LEN]
|
||||
details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
|
||||
|
||||
when = timestamp or datetime.now(timezone.utc)
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=event_type,
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
provider_name=provider_name,
|
||||
# ``collection_id`` / ``collection_name`` are required fields on
|
||||
# ServiceEvent; we use the subject so quiet-hours / dedupe logic
|
||||
# treats different subjects as distinct streams.
|
||||
collection_id=str(subject_id),
|
||||
collection_name=subject_name or str(failure_type),
|
||||
timestamp=when,
|
||||
extra={
|
||||
"failure_type": str(failure_type),
|
||||
"subject_id": subject_id,
|
||||
"subject_name": subject_name,
|
||||
"count": count,
|
||||
"threshold": threshold,
|
||||
"last_error": last_error,
|
||||
"details": dict(details),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Bridge self-monitoring service provider — emits internal-failure events.
|
||||
|
||||
This is a passive provider: it does not connect to anything, never polls,
|
||||
and never subscribes. It exists so the rest of the bridge's CRUD / config /
|
||||
template / target plumbing has a single ``ServiceProvider`` to attach
|
||||
self-monitoring trackers and notification slots to.
|
||||
|
||||
Events are constructed by the server-side helper
|
||||
``services/bridge_self.emit_bridge_self_event`` and pushed into
|
||||
``dispatch_provider_event`` directly — the provider itself is not asked
|
||||
to produce events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.base import (
|
||||
ServiceProvider,
|
||||
ServiceProviderType,
|
||||
)
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
|
||||
# Configuration keys recognised on the bridge_self provider's ``config`` JSON.
|
||||
DEFAULT_POLL_FAILURE_THRESHOLD = 3
|
||||
DEFAULT_DEFERRED_BACKLOG_THRESHOLD = 100
|
||||
DEFAULT_TARGET_FAILURE_THRESHOLD = 5
|
||||
|
||||
|
||||
# Template variables exposed to bridge_self templates.
|
||||
BRIDGE_SELF_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
name="failure_type",
|
||||
type="string",
|
||||
description="Which self-monitoring condition fired",
|
||||
example="poll_failures",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="subject_id",
|
||||
type="int",
|
||||
description="ID of the affected entity (tracker_id, target_id, or 0)",
|
||||
example="42",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="subject_name",
|
||||
type="string",
|
||||
description="Human-readable name of the affected entity",
|
||||
example="My Immich Tracker",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="count",
|
||||
type="int",
|
||||
description="Consecutive failure count or current backlog size",
|
||||
example="3",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="threshold",
|
||||
type="int",
|
||||
description="Configured threshold that was crossed",
|
||||
example="3",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="last_error",
|
||||
type="string",
|
||||
description="Last underlying error message (truncated)",
|
||||
example="Connection refused",
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="details",
|
||||
type="dict",
|
||||
description="Extra structured context for the event",
|
||||
example='{"provider_id": 7}',
|
||||
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class BridgeSelfServiceProvider(ServiceProvider):
|
||||
"""Passive provider — exposes nothing remote, holds only thresholds.
|
||||
|
||||
Polling is a no-op and ``connect`` always succeeds; the bridge itself
|
||||
is what generates events for this provider.
|
||||
"""
|
||||
|
||||
provider_type = ServiceProviderType.BRIDGE_SELF
|
||||
supports_subscription = False
|
||||
|
||||
def __init__(self, name: str = "Bridge Self-Monitoring") -> None:
|
||||
self._name = name
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
collection_ids: list[str],
|
||||
tracker_state: dict[str, Any],
|
||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||
# No external service to poll. Returning empty keeps the contract
|
||||
# so accidental scheduling no-ops cleanly.
|
||||
return [], tracker_state
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(BRIDGE_SELF_VARIABLES)
|
||||
|
||||
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"poll_failure_threshold": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": DEFAULT_POLL_FAILURE_THRESHOLD,
|
||||
"description": "Consecutive tracker poll failures before alerting",
|
||||
},
|
||||
"deferred_backlog_threshold": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": DEFAULT_DEFERRED_BACKLOG_THRESHOLD,
|
||||
"description": "Pending deferred_dispatch rows before alerting",
|
||||
},
|
||||
"target_failure_threshold": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": DEFAULT_TARGET_FAILURE_THRESHOLD,
|
||||
"description": "Consecutive target send failures before alerting",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def list_collections(self) -> list[dict[str, Any]]:
|
||||
# No collection concept — operators don't pick anything for this provider.
|
||||
return []
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
return {"ok": True, "message": "Bridge self-monitoring is always available"}
|
||||
@@ -444,6 +444,133 @@ WEBHOOK_CAPABILITIES = ProviderCapabilities(
|
||||
],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home Assistant provider capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOME_ASSISTANT_CAPABILITIES = ProviderCapabilities(
|
||||
provider_type="home_assistant",
|
||||
display_name="Home Assistant",
|
||||
webhook_based=False,
|
||||
supported_filters=[
|
||||
{
|
||||
"key": "collections",
|
||||
"label": "Entities",
|
||||
"type": "tags",
|
||||
"placeholder": "light.kitchen",
|
||||
},
|
||||
{
|
||||
"key": "entity_glob",
|
||||
"label": "Entity glob",
|
||||
"type": "tags",
|
||||
"placeholder": "light.*",
|
||||
},
|
||||
{
|
||||
"key": "domain_allowlist",
|
||||
"label": "Domains",
|
||||
"type": "tags",
|
||||
"placeholder": "light, binary_sensor",
|
||||
},
|
||||
],
|
||||
notification_slots=[
|
||||
{"name": "message_ha_state_changed", "description": "Entity state changed"},
|
||||
{"name": "message_ha_automation_triggered", "description": "Automation triggered"},
|
||||
{"name": "message_ha_service_called", "description": "HA service called"},
|
||||
{"name": "message_ha_event_fired", "description": "Other HA event fired"},
|
||||
],
|
||||
events=[
|
||||
{"name": "ha_state_changed", "description": "Entity state changed"},
|
||||
{"name": "ha_automation_triggered", "description": "Automation triggered"},
|
||||
{"name": "ha_service_called", "description": "HA service called"},
|
||||
{"name": "ha_event_fired", "description": "Other HA event fired (catch-all)"},
|
||||
],
|
||||
command_slots=[
|
||||
# Response templates
|
||||
{"name": "start", "description": "/start greeting message"},
|
||||
{"name": "help", "description": "/help command listing"},
|
||||
{"name": "status", "description": "/status connection summary"},
|
||||
{"name": "entities", "description": "/entities matching glob"},
|
||||
{"name": "state", "description": "/state single-entity drill-down"},
|
||||
{"name": "areas", "description": "/areas with entity counts"},
|
||||
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||
{"name": "no_results", "description": "Empty results fallback"},
|
||||
# Description slots
|
||||
{"name": "desc_help", "description": "Menu description for /help"},
|
||||
{"name": "desc_status", "description": "Menu description for /status"},
|
||||
{"name": "desc_entities", "description": "Menu description for /entities"},
|
||||
{"name": "desc_state", "description": "Menu description for /state"},
|
||||
{"name": "desc_areas", "description": "Menu description for /areas"},
|
||||
# Usage examples
|
||||
{"name": "usage_entities", "description": "Usage example for /entities"},
|
||||
{"name": "usage_state", "description": "Usage example for /state"},
|
||||
],
|
||||
commands=[
|
||||
{"name": "status", "description": "Show connection status"},
|
||||
{"name": "entities", "description": "List entities (optional glob)"},
|
||||
{"name": "state", "description": "Show state for one entity"},
|
||||
{"name": "areas", "description": "List HA areas with entity counts"},
|
||||
{"name": "help", "description": "Show commands"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge self-monitoring capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRIDGE_SELF_CAPABILITIES = ProviderCapabilities(
|
||||
provider_type="bridge_self",
|
||||
display_name="Bridge Self-Monitoring",
|
||||
webhook_based=False,
|
||||
supported_filters=[],
|
||||
notification_slots=[
|
||||
{
|
||||
"name": "message_bridge_self_poll_failures",
|
||||
"description": "Tracker poll failures crossed threshold",
|
||||
},
|
||||
{
|
||||
"name": "message_bridge_self_deferred_backlog",
|
||||
"description": "Deferred dispatch backlog crossed threshold",
|
||||
},
|
||||
{
|
||||
"name": "message_bridge_self_target_failures",
|
||||
"description": "Target send failures crossed threshold",
|
||||
},
|
||||
],
|
||||
events=[
|
||||
{"name": "bridge_self_poll_failures", "description": "Tracker poll failures"},
|
||||
{"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"},
|
||||
{"name": "bridge_self_target_failures", "description": "Target send failures"},
|
||||
],
|
||||
command_slots=[
|
||||
# Response templates
|
||||
{"name": "start", "description": "/start greeting message"},
|
||||
{"name": "help", "description": "/help command listing"},
|
||||
{"name": "status", "description": "/status full counter snapshot"},
|
||||
{"name": "thresholds", "description": "/thresholds configured alert thresholds"},
|
||||
{"name": "reset", "description": "/reset manual counter reset"},
|
||||
{"name": "health", "description": "/health terse one-line summary"},
|
||||
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||
{"name": "no_results", "description": "Empty results fallback"},
|
||||
# Description slots
|
||||
{"name": "desc_help", "description": "Menu description for /help"},
|
||||
{"name": "desc_status", "description": "Menu description for /status"},
|
||||
{"name": "desc_thresholds", "description": "Menu description for /thresholds"},
|
||||
{"name": "desc_reset", "description": "Menu description for /reset"},
|
||||
{"name": "desc_health", "description": "Menu description for /health"},
|
||||
# Usage examples
|
||||
{"name": "usage_reset", "description": "Usage example for /reset"},
|
||||
],
|
||||
commands=[
|
||||
{"name": "status", "description": "Show current bridge health counters"},
|
||||
{"name": "thresholds", "description": "Show configured alert thresholds"},
|
||||
{"name": "reset", "description": "Manually reset a failure counter"},
|
||||
{"name": "health", "description": "Terse one-line health summary"},
|
||||
{"name": "help", "description": "Show commands"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -456,6 +583,8 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
||||
"nut": NUT_CAPABILITIES,
|
||||
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
||||
"webhook": WEBHOOK_CAPABILITIES,
|
||||
"home_assistant": HOME_ASSISTANT_CAPABILITIES,
|
||||
"bridge_self": BRIDGE_SELF_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Home Assistant service provider implementation."""
|
||||
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
|
||||
from .client import (
|
||||
HomeAssistantApiError,
|
||||
HomeAssistantAuthError,
|
||||
HomeAssistantWSClient,
|
||||
_redact as redact_ha_message,
|
||||
)
|
||||
from .event_parser import parse_event
|
||||
from .provider import (
|
||||
DEFAULT_HA_EVENT_TYPES,
|
||||
HOME_ASSISTANT_VARIABLES,
|
||||
HomeAssistantServiceProvider,
|
||||
)
|
||||
|
||||
# Register HA variables in the global registry — same pattern as the other
|
||||
# providers in this package.
|
||||
registry.register_provider_variables(
|
||||
ServiceProviderType.HOME_ASSISTANT, HOME_ASSISTANT_VARIABLES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HA_EVENT_TYPES",
|
||||
"HOME_ASSISTANT_VARIABLES",
|
||||
"HomeAssistantApiError",
|
||||
"HomeAssistantAuthError",
|
||||
"HomeAssistantServiceProvider",
|
||||
"HomeAssistantWSClient",
|
||||
"parse_event",
|
||||
"redact_ha_message",
|
||||
]
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Home Assistant WebSocket client.
|
||||
|
||||
Implements the slice of the HA WebSocket API we need for Phase 1:
|
||||
|
||||
* Authenticate with a long-lived access token.
|
||||
* Subscribe to events (optionally filtered by ``event_type``).
|
||||
* Fetch the state list (``get_states``) for entity picker UI.
|
||||
* Fetch the entity and area registries to build an ``entity_id -> area_id``
|
||||
lookup that the parser uses to enrich ``state_changed`` events with the
|
||||
area name.
|
||||
* Run an indefinite subscription loop with exponential backoff reconnect.
|
||||
|
||||
The HA protocol reference is at
|
||||
https://developers.home-assistant.io/docs/api/websocket/ — message ids are
|
||||
ascending integers, server replies use the same id, and authentication must
|
||||
complete before any other command is accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantAuthError(Exception):
|
||||
"""Raised when HA rejects our access token. Fatal — no point retrying."""
|
||||
|
||||
|
||||
class HomeAssistantApiError(Exception):
|
||||
"""Raised when an HA WS command returns ``success: false``."""
|
||||
|
||||
|
||||
# Default reconnect backoff: 2s, 4s, 8s, ..., capped at 60s with jitter.
|
||||
_RECONNECT_BASE_SECONDS = 2.0
|
||||
_RECONNECT_MAX_SECONDS = 60.0
|
||||
_RECONNECT_JITTER_RATIO = 0.2
|
||||
|
||||
# Bounded queue between the WS receive loop and the emit consumer. Overflow
|
||||
# drops the oldest event (FIFO) and logs at WARNING — better to lose one
|
||||
# state_changed than fall behind the firehose indefinitely.
|
||||
_EMIT_QUEUE_SIZE = 1000
|
||||
|
||||
|
||||
def _ws_url_from_base(base_url: str) -> str:
|
||||
"""Derive the HA WebSocket URL from the user-provided HTTP(S) base URL.
|
||||
|
||||
``http://homeassistant.local:8123`` -> ``ws://homeassistant.local:8123/api/websocket``.
|
||||
The user enters their normal HA URL; we transform the scheme + append
|
||||
the API path. This keeps the UI single-field and avoids confusion about
|
||||
which URL form to use.
|
||||
|
||||
Userinfo (``user:pass@host``) is **stripped** — credentials embedded in
|
||||
the URL would otherwise flow into log lines and exception strings via
|
||||
``aiohttp`` error messages. The HA WS protocol uses an access-token
|
||||
handshake; HTTP basic auth in the URL is never the intended path.
|
||||
"""
|
||||
parsed = urlparse(base_url.rstrip("/"))
|
||||
if parsed.scheme in ("ws", "wss"):
|
||||
scheme = parsed.scheme
|
||||
elif parsed.scheme == "https":
|
||||
scheme = "wss"
|
||||
else:
|
||||
scheme = "ws"
|
||||
# ``netloc`` may contain ``user:pass@host:port``; ``hostname`` + ``port``
|
||||
# rebuild it without the credential prefix.
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port is not None:
|
||||
netloc = f"{host}:{parsed.port}"
|
||||
else:
|
||||
netloc = host
|
||||
return urlunparse(
|
||||
(scheme, netloc, "/api/websocket", "", "", "")
|
||||
)
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Strip embedded credentials from text before logging.
|
||||
|
||||
``aiohttp`` exception strings include the URL, so a malformed
|
||||
``https://token@host`` would otherwise expose the token. This is a
|
||||
defense-in-depth measure — ``_ws_url_from_base`` already strips
|
||||
userinfo from the connect URL, but third-party libs may quote the
|
||||
user-supplied input separately.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
# Match ``scheme://[user[:pass]@]host`` and drop the userinfo segment.
|
||||
import re
|
||||
return re.sub(
|
||||
r"(?P<scheme>\w+://)(?:[^/@\s]+@)",
|
||||
r"\g<scheme>",
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantWSClient:
|
||||
"""Single-instance WebSocket client for one HA server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
base_url: str,
|
||||
access_token: str,
|
||||
verify_tls: bool = True,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._ws_url = _ws_url_from_base(base_url)
|
||||
self._access_token = access_token
|
||||
self._verify_tls = verify_tls
|
||||
self._id_counter = itertools.count(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection primitives
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(self) -> AsyncIterator[aiohttp.ClientWebSocketResponse]:
|
||||
"""Open a fresh WS, complete the auth handshake, and yield the socket.
|
||||
|
||||
Raises :class:`HomeAssistantAuthError` on invalid token (fatal) and
|
||||
:class:`HomeAssistantApiError` on other handshake failures (caller
|
||||
decides whether to retry).
|
||||
"""
|
||||
ws = await self._session.ws_connect(
|
||||
self._ws_url,
|
||||
ssl=None if self._verify_tls else False,
|
||||
heartbeat=30,
|
||||
autoping=True,
|
||||
)
|
||||
try:
|
||||
await self._authenticate(ws)
|
||||
yield ws
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
async def _authenticate(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
"""Run the HA auth handshake on a freshly-opened socket."""
|
||||
greeting = await ws.receive_json(timeout=10)
|
||||
if greeting.get("type") != "auth_required":
|
||||
raise HomeAssistantApiError(
|
||||
f"Expected auth_required, got {greeting.get('type')!r}"
|
||||
)
|
||||
await ws.send_json({"type": "auth", "access_token": self._access_token})
|
||||
result = await ws.receive_json(timeout=10)
|
||||
msg_type = result.get("type")
|
||||
if msg_type == "auth_ok":
|
||||
return
|
||||
if msg_type == "auth_invalid":
|
||||
raise HomeAssistantAuthError(
|
||||
result.get("message") or "Home Assistant rejected the access token"
|
||||
)
|
||||
raise HomeAssistantApiError(
|
||||
f"Unexpected auth response: {msg_type!r}"
|
||||
)
|
||||
|
||||
async def _send_command(
|
||||
self,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
payload: dict[str, Any],
|
||||
) -> int:
|
||||
"""Send a command with an auto-assigned id; return that id."""
|
||||
msg_id = next(self._id_counter)
|
||||
await ws.send_json({"id": msg_id, **payload})
|
||||
return msg_id
|
||||
|
||||
async def _await_result(
|
||||
self,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
msg_id: int,
|
||||
timeout: float = 15.0,
|
||||
) -> Any:
|
||||
"""Wait for a ``result`` message matching ``msg_id`` and return its payload.
|
||||
|
||||
``time.monotonic`` is the right clock here — wall-clock deadlines
|
||||
would jump on NTP sync, and ``asyncio.get_event_loop().time()``
|
||||
is deprecated when called outside a running-loop context.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HomeAssistantApiError(
|
||||
f"Timed out waiting for result of command id={msg_id}"
|
||||
)
|
||||
msg = await ws.receive_json(timeout=remaining)
|
||||
if msg.get("id") != msg_id:
|
||||
# Ignore unsolicited events that arrive between sending a
|
||||
# request-style command and its result.
|
||||
continue
|
||||
if msg.get("type") != "result":
|
||||
continue
|
||||
if not msg.get("success", False):
|
||||
err = msg.get("error", {})
|
||||
raise HomeAssistantApiError(
|
||||
f"HA command failed: {err.get('code')} {err.get('message')}"
|
||||
)
|
||||
return msg.get("result")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multi-command session
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncIterator["HomeAssistantSession"]:
|
||||
"""Open one authenticated WS and let the caller run multiple commands.
|
||||
|
||||
Each one-shot method (``get_states``, ``get_area_registry``, ...)
|
||||
opens a brand-new connection with a full TCP + WS + auth handshake.
|
||||
For callers that need to chain several queries (e.g. /status: connection
|
||||
check + entity list + area count) that overhead adds up — 3 separate
|
||||
TLS handshakes and 3 auth round-trips for what is really one logical
|
||||
request.
|
||||
|
||||
Usage:
|
||||
|
||||
async with client.session() as sess:
|
||||
states = await sess.get_states()
|
||||
areas = await sess.get_area_registry()
|
||||
|
||||
The session shares the same id counter as the client, so message ids
|
||||
are unique across both one-shot calls and session-scoped calls if
|
||||
they happen to run concurrently against the same client instance.
|
||||
"""
|
||||
async with self._connect() as ws:
|
||||
yield HomeAssistantSession(self, ws)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot commands
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
"""Connect, authenticate, and immediately close. Returns ``(ok, message)``."""
|
||||
try:
|
||||
async with self._connect() as _ws:
|
||||
return True, "OK"
|
||||
except HomeAssistantAuthError as err:
|
||||
return False, f"Auth failed: {err}"
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
return False, f"Connection failed: {err}"
|
||||
except HomeAssistantApiError as err:
|
||||
return False, str(err)
|
||||
|
||||
async def get_states(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the current state of every entity HA knows about."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(ws, {"type": "get_states"})
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_area_registry(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the area registry (``area_id`` -> name + metadata)."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(
|
||||
ws, {"type": "config/area_registry/list"}
|
||||
)
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_registry(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the entity registry (entity_id -> area_id + metadata)."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(
|
||||
ws, {"type": "config/entity_registry/list"}
|
||||
)
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_to_area_lookup(self) -> dict[str, str]:
|
||||
"""Build ``{entity_id: area_name}`` using the entity + area registries.
|
||||
|
||||
Best-effort: returns an empty dict on any failure so the parser still
|
||||
works without area enrichment.
|
||||
"""
|
||||
try:
|
||||
entities = await self.get_entity_registry()
|
||||
areas = await self.get_area_registry()
|
||||
except (HomeAssistantApiError, aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
_LOGGER.warning("Could not fetch HA registry, areas disabled: %s", err)
|
||||
return {}
|
||||
area_names = {a.get("area_id"): a.get("name") for a in areas if a.get("area_id")}
|
||||
lookup: dict[str, str] = {}
|
||||
for entry in entities:
|
||||
entity_id = entry.get("entity_id")
|
||||
area_id = entry.get("area_id")
|
||||
if not isinstance(entity_id, str) or not area_id:
|
||||
continue
|
||||
name = area_names.get(area_id)
|
||||
if name:
|
||||
lookup[entity_id] = str(name)
|
||||
return lookup
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscription loop with reconnect
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_subscription(
|
||||
self,
|
||||
on_event: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
event_types: list[str] | None = None,
|
||||
on_status_change: Callable[[str, str | None], None] | None = None,
|
||||
refresh_areas: Callable[[], Awaitable[dict[str, str]]] | None = None,
|
||||
) -> None:
|
||||
"""Run an indefinite subscription loop, reconnecting on drop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
on_event:
|
||||
Coroutine called with the inner ``event`` dict (the WS envelope is
|
||||
stripped). Slow callbacks apply TCP backpressure naturally; the
|
||||
internal queue prevents unbounded memory growth if the callback
|
||||
stalls.
|
||||
event_types:
|
||||
Restrict the subscription to these HA event types. ``None`` or
|
||||
empty subscribes to everything (very loud — only use for debug).
|
||||
on_status_change:
|
||||
Callback invoked with ``("connected", None)`` after a successful
|
||||
handshake and ``("disconnected", reason)`` when a connection drops.
|
||||
Useful for surfacing connection state in the event log.
|
||||
refresh_areas:
|
||||
Optional coroutine called on each (re)connect to refresh the
|
||||
area lookup. The result is not used by ``run_subscription``
|
||||
itself — the caller stores it where its ``on_event`` can read.
|
||||
"""
|
||||
attempt = 0
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=_EMIT_QUEUE_SIZE)
|
||||
overflow_count = 0
|
||||
|
||||
async def _drain() -> None:
|
||||
while True:
|
||||
evt = await queue.get()
|
||||
try:
|
||||
await on_event(evt)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("on_event callback raised; continuing")
|
||||
finally:
|
||||
queue.task_done()
|
||||
|
||||
drain_task = asyncio.create_task(_drain(), name="ha-emit-drain")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
async with self._connect() as ws:
|
||||
attempt = 0
|
||||
if on_status_change is not None:
|
||||
on_status_change("connected", None)
|
||||
if refresh_areas is not None:
|
||||
try:
|
||||
# Note: refresh_areas opens its own WS in our
|
||||
# current design (each one-shot command does).
|
||||
# Fine for v1 — a few hundred ms once per
|
||||
# (re)connect.
|
||||
await refresh_areas()
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Area refresh failed; continuing without")
|
||||
|
||||
# Subscribe. Passing per-event-type subscriptions is
|
||||
# cheaper than subscribing to everything and filtering
|
||||
# in Python — HA does the filtering.
|
||||
if event_types:
|
||||
for evt_type in event_types:
|
||||
sub_id = await self._send_command(
|
||||
ws,
|
||||
{"type": "subscribe_events", "event_type": evt_type},
|
||||
)
|
||||
await self._await_result(ws, sub_id)
|
||||
else:
|
||||
sub_id = await self._send_command(
|
||||
ws, {"type": "subscribe_events"}
|
||||
)
|
||||
await self._await_result(ws, sub_id)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
payload = msg.json()
|
||||
if payload.get("type") != "event":
|
||||
continue
|
||||
event_obj = payload.get("event")
|
||||
if not isinstance(event_obj, dict):
|
||||
continue
|
||||
try:
|
||||
queue.put_nowait(event_obj)
|
||||
except asyncio.QueueFull:
|
||||
overflow_count += 1
|
||||
if overflow_count % 50 == 1:
|
||||
_LOGGER.warning(
|
||||
"HA event queue full, dropped %d events so far "
|
||||
"(consumer is slower than HA event rate)",
|
||||
overflow_count,
|
||||
)
|
||||
# Drop oldest, retry put. This keeps the
|
||||
# most recent state visible at the cost
|
||||
# of older transient changes.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.task_done()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(event_obj)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSING,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
raise aiohttp.ClientConnectionError(
|
||||
f"WS closed: {msg.type.name}"
|
||||
)
|
||||
else:
|
||||
# PING/PONG handled by aiohttp autoping=True;
|
||||
# BINARY/CONTINUATION are not used by HA today.
|
||||
# Log at debug so a future protocol change is
|
||||
# visible without spamming production logs.
|
||||
_LOGGER.debug(
|
||||
"Ignored WS message of type %s", msg.type.name,
|
||||
)
|
||||
except HomeAssistantAuthError as err:
|
||||
# Fatal — caller must fix the access token. Reraise so
|
||||
# the provider can mark itself unhealthy.
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", _redact(f"auth: {err}"))
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", "cancelled")
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
redacted = _redact(str(err))
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", redacted)
|
||||
delay = min(
|
||||
_RECONNECT_BASE_SECONDS * (2 ** attempt),
|
||||
_RECONNECT_MAX_SECONDS,
|
||||
)
|
||||
delay *= 1 + random.uniform(-_RECONNECT_JITTER_RATIO, _RECONNECT_JITTER_RATIO)
|
||||
_LOGGER.warning(
|
||||
"HA WS connection lost (%s); reconnecting in %.1fs",
|
||||
redacted, delay,
|
||||
)
|
||||
attempt = min(attempt + 1, 10)
|
||||
await asyncio.sleep(delay)
|
||||
finally:
|
||||
drain_task.cancel()
|
||||
# Drain task may finish via CancelledError (normal) or via an
|
||||
# unhandled exception thrown by on_event. Either way is fine here
|
||||
# — we're tearing down. Split the two cases for clarity rather
|
||||
# than catching `Exception` + `CancelledError` in one clause.
|
||||
try:
|
||||
await drain_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("HA drain task raised during shutdown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-command session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HomeAssistantSession:
|
||||
"""A multi-command HA WS session bound to a single authenticated socket.
|
||||
|
||||
Created via :meth:`HomeAssistantWSClient.session`. Use when you need to
|
||||
issue several commands in a row — sharing the connection saves the TCP
|
||||
+ WS + auth round trips for every command after the first.
|
||||
|
||||
The session forwards id assignment to the parent client's monotonic
|
||||
counter so ids stay unique across all sessions sharing the same client.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: HomeAssistantWSClient,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._ws = ws
|
||||
|
||||
async def send(self, payload: dict[str, Any], timeout: float = 15.0) -> Any:
|
||||
"""Send one command and wait for its ``result`` envelope."""
|
||||
msg_id = await self._client._send_command(self._ws, payload)
|
||||
return await self._client._await_result(self._ws, msg_id, timeout=timeout)
|
||||
|
||||
async def get_states(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "get_states"})
|
||||
return list(result or [])
|
||||
|
||||
async def get_area_registry(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "config/area_registry/list"})
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_registry(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "config/entity_registry/list"})
|
||||
return list(result or [])
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Home Assistant event parser — HA WebSocket event dict -> ServiceEvent.
|
||||
|
||||
The HA event bus delivers events with this envelope:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"id": 7,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": { ... event-type-specific ... },
|
||||
"origin": "LOCAL",
|
||||
"time_fired": "2026-05-13T12:34:56.789Z",
|
||||
"context": { ... }
|
||||
}
|
||||
}
|
||||
|
||||
The parser accepts the inner ``event`` dict (the WS client strips the outer
|
||||
envelope before calling us) and emits a :class:`ServiceEvent` ready for the
|
||||
existing dispatch path. Areas are looked up via an optional ``area_lookup``
|
||||
mapping so the parser stays pure — the WS client maintains the registry
|
||||
cache and passes its current snapshot on each call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Defensive caps for fields that get persisted to the event_log row. Home
|
||||
# Assistant's own constraints keep entity ids well under 70 chars, but a
|
||||
# misbehaving custom integration could emit kilobyte-sized strings that
|
||||
# would bloat the JSON details column.
|
||||
_MAX_ENTITY_ID_LEN = 255
|
||||
_MAX_EVENT_DATA_BYTES = 4096
|
||||
|
||||
|
||||
def _parse_time_fired(raw: Any) -> datetime:
|
||||
"""Parse HA's ``time_fired`` ISO string, falling back to now() on garbage.
|
||||
|
||||
HA always sends UTC with a ``Z`` suffix or explicit ``+00:00``. Datetime
|
||||
parsing is wrapped because a malformed payload should not break the
|
||||
pipeline — better to dispatch with a slightly-off timestamp than drop.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
# ``datetime.fromisoformat`` accepts ``+00:00`` natively; rewrite
|
||||
# the trailing ``Z`` since pre-3.11 stdlib rejects it.
|
||||
cleaned = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
||||
return datetime.fromisoformat(cleaned)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Unparseable HA time_fired %r, using now()", raw)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _domain_of(entity_id: str) -> str:
|
||||
"""Return the HA domain prefix (``light.kitchen`` -> ``light``)."""
|
||||
if "." in entity_id:
|
||||
return entity_id.split(".", 1)[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _friendly_name(state_obj: dict[str, Any] | None, entity_id: str) -> str:
|
||||
"""Pull ``friendly_name`` from attributes or fall back to entity_id."""
|
||||
if not state_obj:
|
||||
return entity_id
|
||||
attrs = state_obj.get("attributes") or {}
|
||||
name = attrs.get("friendly_name")
|
||||
return str(name) if name else entity_id
|
||||
|
||||
|
||||
def parse_event(
|
||||
ha_event: dict[str, Any],
|
||||
provider_name: str,
|
||||
area_lookup: dict[str, str] | None = None,
|
||||
) -> ServiceEvent | None:
|
||||
"""Parse one HA event dict into a :class:`ServiceEvent`.
|
||||
|
||||
Returns None for malformed payloads (missing ``event_type`` etc.) so the
|
||||
caller can drop without raising. Genuine network/parsing exceptions
|
||||
bubble up — only known-bad payload shapes return None.
|
||||
"""
|
||||
if not isinstance(ha_event, dict):
|
||||
return None
|
||||
event_type_raw = ha_event.get("event_type")
|
||||
if not isinstance(event_type_raw, str):
|
||||
return None
|
||||
|
||||
data = ha_event.get("data") or {}
|
||||
timestamp = _parse_time_fired(ha_event.get("time_fired"))
|
||||
area_lookup = area_lookup or {}
|
||||
|
||||
if event_type_raw == "state_changed":
|
||||
return _parse_state_changed(data, timestamp, provider_name, area_lookup)
|
||||
if event_type_raw == "automation_triggered":
|
||||
return _parse_automation_triggered(data, timestamp, provider_name)
|
||||
if event_type_raw == "call_service":
|
||||
return _parse_call_service(data, timestamp, provider_name)
|
||||
# Everything else maps to the generic "event_fired" slot. Tracking
|
||||
# configs decide whether to enable this loud catch-all.
|
||||
return _parse_generic_event(event_type_raw, data, timestamp, provider_name)
|
||||
|
||||
|
||||
def _parse_state_changed(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
area_lookup: dict[str, str],
|
||||
) -> ServiceEvent | None:
|
||||
entity_id = data.get("entity_id")
|
||||
if not isinstance(entity_id, str):
|
||||
return None
|
||||
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
|
||||
|
||||
old_state_obj = data.get("old_state") if isinstance(data.get("old_state"), dict) else None
|
||||
new_state_obj = data.get("new_state") if isinstance(data.get("new_state"), dict) else None
|
||||
|
||||
# ``new_state`` is None when an entity is removed — surface it as a
|
||||
# transition to the literal string "removed" so templates can branch.
|
||||
old_state_val = old_state_obj.get("state") if old_state_obj else None
|
||||
new_state_val = new_state_obj.get("state") if new_state_obj else "removed"
|
||||
|
||||
attributes = (new_state_obj or {}).get("attributes") or {}
|
||||
friendly_name = _friendly_name(new_state_obj or old_state_obj, entity_id)
|
||||
domain = _domain_of(entity_id)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": friendly_name,
|
||||
"domain": domain,
|
||||
"old_state": old_state_val,
|
||||
"new_state": new_state_val,
|
||||
"attributes": attributes,
|
||||
"device_class": attributes.get("device_class"),
|
||||
"unit_of_measurement": attributes.get("unit_of_measurement"),
|
||||
"area": area_lookup.get(entity_id),
|
||||
"ha_event_type": "state_changed",
|
||||
}
|
||||
if new_state_obj and "last_changed" in new_state_obj:
|
||||
extra["last_changed"] = new_state_obj["last_changed"]
|
||||
if new_state_obj and "last_updated" in new_state_obj:
|
||||
extra["last_updated"] = new_state_obj["last_updated"]
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_STATE_CHANGED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=entity_id,
|
||||
collection_name=friendly_name,
|
||||
timestamp=timestamp,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _parse_automation_triggered(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
entity_id = data.get("entity_id")
|
||||
if isinstance(entity_id, str):
|
||||
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
|
||||
automation_name = data.get("name") or (entity_id if isinstance(entity_id, str) else "automation")
|
||||
source = data.get("source") or ""
|
||||
|
||||
collection_id = entity_id if isinstance(entity_id, str) else f"automation.{automation_name}"
|
||||
collection_id = collection_id[:_MAX_ENTITY_ID_LEN]
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_AUTOMATION_TRIGGERED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=collection_id,
|
||||
collection_name=str(automation_name),
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"entity_id": entity_id,
|
||||
"automation_name": str(automation_name),
|
||||
"trigger_source": str(source),
|
||||
"ha_event_type": "automation_triggered",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_call_service(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
domain = data.get("domain")
|
||||
service = data.get("service")
|
||||
if not isinstance(domain, str) or not isinstance(service, str):
|
||||
return None
|
||||
domain = domain[:_MAX_ENTITY_ID_LEN]
|
||||
service = service[:_MAX_ENTITY_ID_LEN]
|
||||
service_data = data.get("service_data") if isinstance(data.get("service_data"), dict) else {}
|
||||
qualified = f"{domain}.{service}"
|
||||
target_entity = None
|
||||
if isinstance(service_data, dict):
|
||||
raw_target = service_data.get("entity_id")
|
||||
if isinstance(raw_target, str):
|
||||
target_entity = raw_target
|
||||
elif isinstance(raw_target, list) and raw_target:
|
||||
target_entity = ", ".join(str(x) for x in raw_target)
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_SERVICE_CALLED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=qualified,
|
||||
collection_name=qualified,
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"service_domain": domain,
|
||||
"service_name": service,
|
||||
"service_called": qualified,
|
||||
"service_data": service_data,
|
||||
"target_entity": target_entity,
|
||||
"ha_event_type": "call_service",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_generic_event(
|
||||
event_type_raw: str,
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
event_type_raw = event_type_raw[:_MAX_ENTITY_ID_LEN]
|
||||
# Cap the serialized payload so a custom HA integration that emits
|
||||
# a multi-megabyte event_data dict doesn't blow up the event_log JSON
|
||||
# column. Templates can still reference fields up to the cap; beyond it
|
||||
# the dict is replaced with a marker so the limit is visible to authors.
|
||||
capped_data: Any = data
|
||||
try:
|
||||
serialized = json.dumps(data, default=str)
|
||||
except (TypeError, ValueError):
|
||||
# Unserializable payload — keep the dict in-memory so templates can
|
||||
# still read scalar fields, but flag the size as 0 to avoid surprises.
|
||||
serialized = ""
|
||||
if len(serialized.encode("utf-8")) > _MAX_EVENT_DATA_BYTES:
|
||||
capped_data = {
|
||||
"_truncated": True,
|
||||
"_original_size_bytes": len(serialized.encode("utf-8")),
|
||||
"_note": f"event_data exceeded {_MAX_EVENT_DATA_BYTES}B and was dropped",
|
||||
}
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_EVENT_FIRED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=event_type_raw,
|
||||
collection_name=event_type_raw,
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"ha_event_type": event_type_raw,
|
||||
"event_data": capped_data,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Home Assistant service provider — WebSocket subscription based.
|
||||
|
||||
Unlike polling providers (Immich, NUT, Google Photos) and webhook providers
|
||||
(Gitea, Planka), the HA provider maintains a long-lived WebSocket connection
|
||||
to the HA server and pushes events into the dispatch pipeline as they
|
||||
arrive. The lifecycle is owned by the server-side subscription manager
|
||||
(see ``services/ha_subscription.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.base import (
|
||||
EventEmitCallback,
|
||||
ServiceProvider,
|
||||
ServiceProviderType,
|
||||
)
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
from .client import HomeAssistantWSClient
|
||||
from .event_parser import parse_event
|
||||
|
||||
|
||||
# Status callback signature: ``(state, detail)`` where ``state`` is one of
|
||||
# ``"connected"`` / ``"disconnected"`` and ``detail`` is an optional already-
|
||||
# redacted reason string (or None on connect).
|
||||
StatusChangeCallback = Callable[[str, str | None], None]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Home Assistant template variables exposed to Jinja2.
|
||||
HOME_ASSISTANT_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
name="entity_id",
|
||||
type="string",
|
||||
description="HA entity id (e.g. light.kitchen)",
|
||||
example="light.kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="friendly_name",
|
||||
type="string",
|
||||
description="Human-readable entity name from attributes.friendly_name",
|
||||
example="Kitchen Light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="domain",
|
||||
type="string",
|
||||
description="HA domain prefix of the entity (e.g. light, sensor, binary_sensor)",
|
||||
example="light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="old_state",
|
||||
type="string",
|
||||
description="Previous state string before the change",
|
||||
example="off",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="new_state",
|
||||
type="string",
|
||||
description="New state string (literal 'removed' when entity was deleted)",
|
||||
example="on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="attributes",
|
||||
type="dict",
|
||||
description="Full attributes dict of the new state",
|
||||
example='{"brightness": 255, "color_mode": "brightness"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="device_class",
|
||||
type="string",
|
||||
description="Device class from attributes (motion, door, temperature, ...)",
|
||||
example="motion",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="unit_of_measurement",
|
||||
type="string",
|
||||
description="Unit suffix for numeric sensors",
|
||||
example="°C",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="area",
|
||||
type="string",
|
||||
description="Area name from the HA area registry (empty when not assigned)",
|
||||
example="Kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="last_changed",
|
||||
type="string",
|
||||
description="ISO timestamp of last state change",
|
||||
example="2026-05-13T12:34:56.789+00:00",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="last_updated",
|
||||
type="string",
|
||||
description="ISO timestamp of last attribute or state update",
|
||||
example="2026-05-13T12:34:56.789+00:00",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="automation_name",
|
||||
type="string",
|
||||
description="Automation name (automation_triggered events)",
|
||||
example="Front Door Notification",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="trigger_source",
|
||||
type="string",
|
||||
description="Why an automation fired (automation_triggered events)",
|
||||
example="state of binary_sensor.front_door",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_called",
|
||||
type="string",
|
||||
description="Qualified service name (call_service events)",
|
||||
example="light.turn_on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_domain",
|
||||
type="string",
|
||||
description="Service domain (call_service events)",
|
||||
example="light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_name",
|
||||
type="string",
|
||||
description="Service name within domain (call_service events)",
|
||||
example="turn_on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_data",
|
||||
type="dict",
|
||||
description="Service payload (call_service events)",
|
||||
example='{"entity_id": "light.kitchen"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="target_entity",
|
||||
type="string",
|
||||
description="entity_id targeted by a service call (comma-joined for multi-target)",
|
||||
example="light.kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ha_event_type",
|
||||
type="string",
|
||||
description="Raw HA event_type (state_changed, automation_triggered, ...)",
|
||||
example="state_changed",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="event_data",
|
||||
type="dict",
|
||||
description="Raw event data (generic event_fired events)",
|
||||
example='{"key": "value"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Default event types subscribed to when the user does not override. Only
|
||||
# state_changed is on by default — the others are loud and opt-in via the
|
||||
# tracking-config event checkboxes.
|
||||
DEFAULT_HA_EVENT_TYPES: tuple[str, ...] = ("state_changed",)
|
||||
|
||||
|
||||
class HomeAssistantServiceProvider(ServiceProvider):
|
||||
"""Home Assistant WebSocket subscription provider."""
|
||||
|
||||
provider_type = ServiceProviderType.HOME_ASSISTANT
|
||||
supports_subscription = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
access_token: str,
|
||||
verify_tls: bool = True,
|
||||
event_types: list[str] | None = None,
|
||||
name: str = "Home Assistant",
|
||||
) -> None:
|
||||
self._client = HomeAssistantWSClient(
|
||||
session=session,
|
||||
base_url=url,
|
||||
access_token=access_token,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
self._name = name
|
||||
self._event_types = list(event_types) if event_types else list(DEFAULT_HA_EVENT_TYPES)
|
||||
# ``_area_lookup`` is refreshed on every (re)connect by run_subscription's
|
||||
# ``refresh_areas`` hook so the parser can enrich state_changed events
|
||||
# with the current area name.
|
||||
self._area_lookup: dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def client(self) -> HomeAssistantWSClient:
|
||||
return self._client
|
||||
|
||||
async def connect(self) -> bool:
|
||||
ok, _ = await self._client.test_connection()
|
||||
return ok
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
# Session lifecycle is managed by the caller; the WS connection is
|
||||
# owned by run_subscription which exits on cancel.
|
||||
return None
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
collection_ids: list[str],
|
||||
tracker_state: dict[str, Any],
|
||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||
# Subscription-based ingest. The polling scheduler MUST NOT call us
|
||||
# — the subscription manager owns this provider's lifecycle instead.
|
||||
return [], tracker_state
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
emit: EventEmitCallback,
|
||||
on_status_change: StatusChangeCallback | None = None,
|
||||
) -> None:
|
||||
async def _on_event(ha_event: dict[str, Any]) -> None:
|
||||
event = parse_event(
|
||||
ha_event,
|
||||
provider_name=self._name,
|
||||
area_lookup=self._area_lookup,
|
||||
)
|
||||
if event is None:
|
||||
return
|
||||
await emit(event)
|
||||
|
||||
async def _refresh_areas() -> dict[str, str]:
|
||||
try:
|
||||
self._area_lookup = await self._client.get_entity_to_area_lookup()
|
||||
except Exception: # noqa: BLE001
|
||||
# Best-effort: keep the previous lookup on failure.
|
||||
_LOGGER.exception("Failed to refresh HA area lookup")
|
||||
return self._area_lookup
|
||||
|
||||
await self._client.run_subscription(
|
||||
on_event=_on_event,
|
||||
event_types=self._event_types,
|
||||
refresh_areas=_refresh_areas,
|
||||
on_status_change=on_status_change,
|
||||
)
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(HOME_ASSISTANT_VARIABLES)
|
||||
|
||||
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Home Assistant base URL (http://homeassistant.local:8123)",
|
||||
"example": "http://homeassistant.local:8123",
|
||||
},
|
||||
"access_token": {
|
||||
"type": "string",
|
||||
"description": "Long-lived access token (HA Profile -> Long-Lived Access Tokens)",
|
||||
"secret": True,
|
||||
},
|
||||
"verify_tls": {
|
||||
"type": "boolean",
|
||||
"description": "Validate TLS certificate. Disable only for self-signed HA setups on trusted networks.",
|
||||
"default": True,
|
||||
},
|
||||
"event_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "HA event types to subscribe to. Defaults to ['state_changed'].",
|
||||
"default": list(DEFAULT_HA_EVENT_TYPES),
|
||||
},
|
||||
},
|
||||
"required": ["url", "access_token"],
|
||||
}
|
||||
|
||||
async def list_collections(self) -> list[dict[str, Any]]:
|
||||
"""Return the current entity list for the entity-picker UI."""
|
||||
try:
|
||||
states = await self._client.get_states()
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("Could not fetch HA states: %s", err)
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for state in states:
|
||||
entity_id = state.get("entity_id")
|
||||
if not isinstance(entity_id, str):
|
||||
continue
|
||||
attrs = state.get("attributes") or {}
|
||||
out.append({
|
||||
"id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": state.get("state"),
|
||||
"domain": entity_id.split(".", 1)[0] if "." in entity_id else "",
|
||||
})
|
||||
return out
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
ok, message = await self._client.test_connection()
|
||||
return {"ok": ok, "message": message}
|
||||
@@ -29,10 +29,21 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
|
||||
# raw api_key out of dict keys in case of a memory dump.
|
||||
_USERS_CACHE_TTL_SECONDS = 3600
|
||||
_users_cache_lock = asyncio.Lock()
|
||||
# Lazy init: ``asyncio.Lock()`` at module import binds to whichever event
|
||||
# loop is current at import time (often none, or the wrong one when tests
|
||||
# spin up dedicated loops). Defer creation to first use.
|
||||
_users_cache_lock: asyncio.Lock | None = None
|
||||
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
|
||||
|
||||
def _get_users_cache_lock() -> asyncio.Lock:
|
||||
"""Return the module users-cache lock, creating it on first call."""
|
||||
global _users_cache_lock
|
||||
if _users_cache_lock is None:
|
||||
_users_cache_lock = asyncio.Lock()
|
||||
return _users_cache_lock
|
||||
|
||||
|
||||
def _users_cache_key(url: str, api_key: str) -> str:
|
||||
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
||||
return digest[:32]
|
||||
@@ -51,7 +62,7 @@ async def _get_cached_users(
|
||||
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _users_cache_lock:
|
||||
async with _get_users_cache_lock():
|
||||
# Re-check after acquiring the lock — another coroutine may have
|
||||
# refreshed the entry while we waited.
|
||||
entry = _users_cache.get(key)
|
||||
|
||||
@@ -200,10 +200,28 @@ class NutServiceProvider(ServiceProvider):
|
||||
try:
|
||||
for ups_name in collection_ids:
|
||||
prev = tracker_state.get(ups_name, {})
|
||||
# First-ever observation has no baseline — emitting transition
|
||||
# events for whatever flags the device happens to carry would
|
||||
# spam the user with "OB"/"LB"/"REPLBATT" alerts on every fresh
|
||||
# tracker even when nothing changed. Seed state silently and
|
||||
# skip event emission until the next poll provides a baseline.
|
||||
is_first_observation = ups_name not in tracker_state
|
||||
try:
|
||||
variables = await client.list_var(ups_name)
|
||||
data = NutUpsData.from_variables(ups_name, variables)
|
||||
|
||||
if is_first_observation:
|
||||
new_state[ups_name] = {
|
||||
"name": data.description or ups_name,
|
||||
"status": data.status,
|
||||
"battery_charge": data.battery_charge,
|
||||
"comms_ok": True,
|
||||
"asset_ids": [],
|
||||
"pending_asset_ids": [],
|
||||
"shared": False,
|
||||
}
|
||||
continue
|
||||
|
||||
# Check for comms restored
|
||||
if not prev.get("comms_ok", True):
|
||||
events.append(self._make_event(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Upstream release-check providers.
|
||||
|
||||
This package is intentionally separate from :mod:`notify_bridge_core.providers`:
|
||||
|
||||
* service providers are user-configured entities persisted per-tenant in the DB;
|
||||
* release providers are admin-level upstream-version probes selected by setting,
|
||||
with at most one active provider per installation.
|
||||
|
||||
Mixing them in one enum/factory bled responsibilities and complicated future
|
||||
additions (e.g. a GitHub release provider that has nothing to do with Gitea
|
||||
service integrations).
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
ReleaseErrorCode,
|
||||
ReleaseInfo,
|
||||
ReleaseProvider,
|
||||
ReleaseProviderKind,
|
||||
ReleaseTestResult,
|
||||
is_valid_repo,
|
||||
)
|
||||
from .registry import build_release_provider
|
||||
|
||||
__all__ = [
|
||||
"ReleaseErrorCode",
|
||||
"ReleaseInfo",
|
||||
"ReleaseProvider",
|
||||
"ReleaseProviderKind",
|
||||
"ReleaseTestResult",
|
||||
"build_release_provider",
|
||||
"is_valid_repo",
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""ReleaseProvider abstraction and shared tag/version utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import ClassVar, Protocol, TypedDict, runtime_checkable
|
||||
|
||||
|
||||
class ReleaseProviderKind(str, Enum):
|
||||
"""Supported upstream release-check providers."""
|
||||
|
||||
DISABLED = "disabled"
|
||||
GITEA = "gitea"
|
||||
GITHUB = "github"
|
||||
|
||||
|
||||
# Single source of truth for `release_error` taxonomy. Surfaced into the cached
|
||||
# `AppSetting`, returned via the API, and translated by the frontend.
|
||||
class ReleaseErrorCode(str, Enum):
|
||||
DISABLED = "disabled"
|
||||
MISCONFIGURED = "misconfigured"
|
||||
PROVIDER_CHANGED = "provider_changed"
|
||||
NO_RELEASE_FOUND = "no_release_found"
|
||||
NETWORK_ERROR = "network_error"
|
||||
HTTP_ERROR = "http_error"
|
||||
PARSE_ERROR = "parse_error"
|
||||
UNSAFE_URL = "unsafe_url"
|
||||
NOT_IMPLEMENTED = "not_implemented"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
"""Normalised release metadata returned by a provider."""
|
||||
|
||||
tag: str
|
||||
version: str
|
||||
name: str | None = None
|
||||
body: str | None = None
|
||||
url: str | None = None
|
||||
published_at: str | None = None
|
||||
prerelease: bool = False
|
||||
draft: bool = False
|
||||
|
||||
|
||||
class ReleaseTestResult(TypedDict):
|
||||
"""Structured shape returned by :meth:`ReleaseProvider.test`."""
|
||||
|
||||
ok: bool
|
||||
info: ReleaseInfo | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReleaseProvider(Protocol):
|
||||
"""Protocol implemented by every release provider.
|
||||
|
||||
Implementations are expected to be safe to instantiate without external
|
||||
side effects — connectivity is deferred until :meth:`fetch_latest` or
|
||||
:meth:`test` is awaited.
|
||||
"""
|
||||
|
||||
kind: ClassVar[ReleaseProviderKind]
|
||||
|
||||
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
|
||||
"""Return the latest release, or ``None`` if there is nothing to report."""
|
||||
|
||||
async def test(self) -> ReleaseTestResult:
|
||||
"""Probe the upstream and return a structured status payload."""
|
||||
|
||||
|
||||
# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def is_valid_repo(repo: str) -> bool:
|
||||
"""``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""
|
||||
|
||||
return bool(repo) and _REPO_RE.match(repo) is not None
|
||||
|
||||
|
||||
_TAG_NUMERIC = re.compile(r"\d+")
|
||||
# Stop reading numeric segments at the first non-digit-non-dot character so
|
||||
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
|
||||
_HEAD_SPLIT = re.compile(r"[^0-9.]")
|
||||
|
||||
|
||||
def normalise_version(tag: str) -> str:
|
||||
"""Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""
|
||||
|
||||
if not tag:
|
||||
return ""
|
||||
cleaned = tag.strip()
|
||||
if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
|
||||
cleaned = cleaned[1:]
|
||||
return cleaned
|
||||
|
||||
|
||||
def _split_version(version: str) -> tuple[tuple[int, ...], str]:
|
||||
"""Split a version into (numeric segments, prerelease suffix).
|
||||
|
||||
A non-empty prerelease suffix marks the version as pre-stable. We use it
|
||||
as a tie-break only — when numeric segments are equal a stable build
|
||||
sorts strictly newer than its pre-release counterpart (``0.7.2`` >
|
||||
``0.7.2-rc1``), which prevents the badge from flickering between
|
||||
"up to date" and "downgrade available" on installs that ship the GA.
|
||||
"""
|
||||
|
||||
if not version:
|
||||
return (), ""
|
||||
work = version.split("+", 1)[0]
|
||||
if "-" in work:
|
||||
head, _, suffix = work.partition("-")
|
||||
else:
|
||||
# Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
|
||||
# first non-digit-non-dot is treated as the suffix.
|
||||
m = _HEAD_SPLIT.search(work)
|
||||
if m and m.start() > 0:
|
||||
head, suffix = work[: m.start()], work[m.start():]
|
||||
else:
|
||||
head, suffix = work, ""
|
||||
segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
|
||||
return segments, suffix.strip()
|
||||
|
||||
|
||||
def compare_versions(a: str, b: str) -> int:
|
||||
"""Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.
|
||||
|
||||
Numeric segments win. When numerically equal, *stable* (no suffix) beats
|
||||
*prerelease* (any non-empty suffix); two equally-prereleased versions
|
||||
compare equal — we deliberately do not order ``rc2`` over ``rc1`` because
|
||||
that requires real semver parsing and would only matter for downgrades.
|
||||
"""
|
||||
|
||||
sa, suffix_a = _split_version(normalise_version(a))
|
||||
sb, suffix_b = _split_version(normalise_version(b))
|
||||
length = max(len(sa), len(sb))
|
||||
for i in range(length):
|
||||
x = sa[i] if i < len(sa) else 0
|
||||
y = sb[i] if i < len(sb) else 0
|
||||
if x != y:
|
||||
return 1 if x > y else -1
|
||||
# Equal numerics — stable beats prerelease.
|
||||
if not suffix_a and suffix_b:
|
||||
return 1
|
||||
if suffix_a and not suffix_b:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def is_newer(candidate: str, baseline: str) -> bool:
|
||||
"""``True`` when ``candidate`` is strictly newer than ``baseline``."""
|
||||
|
||||
return compare_versions(candidate, baseline) > 0
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Gitea release provider — queries ``/api/v1/repos/{owner}/{repo}/releases``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import ClassVar
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..notifications.ssrf import UnsafeURLError, avalidate_outbound_url
|
||||
from .base import (
|
||||
ReleaseErrorCode,
|
||||
ReleaseInfo,
|
||||
ReleaseProviderKind,
|
||||
ReleaseTestResult,
|
||||
is_valid_repo,
|
||||
normalise_version,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Cap upstream response body — release lists are normally a few KB; anything
|
||||
# beyond this is either a misconfigured target or a malicious payload.
|
||||
_MAX_BODY_BYTES = 1_000_000
|
||||
|
||||
|
||||
class GiteaReleaseProvider:
|
||||
"""Anonymous Gitea release probe.
|
||||
|
||||
Hits the ``releases`` endpoint (not ``releases/latest``) because the latter
|
||||
skips pre-releases unconditionally — we want to honour the caller's
|
||||
``include_prereleases`` flag instead of relying on Gitea's filtering.
|
||||
"""
|
||||
|
||||
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITEA
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, url: str, repo: str) -> None:
|
||||
if not url:
|
||||
raise ValueError("Gitea release provider requires a base URL")
|
||||
if not is_valid_repo(repo):
|
||||
raise ValueError(
|
||||
"Gitea release provider requires repo as 'owner/name' "
|
||||
"(alphanumerics, dot, dash, underscore only)"
|
||||
)
|
||||
self._session = session
|
||||
self._url = url.rstrip("/")
|
||||
self._repo = repo.strip("/")
|
||||
|
||||
@property
|
||||
def _endpoint(self) -> str:
|
||||
return f"{self._url}/api/v1/repos/{self._repo}/releases"
|
||||
|
||||
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
|
||||
try:
|
||||
await avalidate_outbound_url(self._endpoint)
|
||||
except UnsafeURLError as err:
|
||||
_LOGGER.warning("Gitea release URL rejected by SSRF guard: %s", err)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with self._session.get(
|
||||
self._endpoint,
|
||||
params={"limit": "20", "page": "1", "draft": "false"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
_LOGGER.warning(
|
||||
"Gitea releases fetch failed: HTTP %s for %s",
|
||||
response.status, self._endpoint,
|
||||
)
|
||||
return None
|
||||
# Enforce a size cap without trusting chunked encoding: read
|
||||
# the whole body (aiohttp buffers it) but reject anything that
|
||||
# advertised more than the cap up front, and bail if it grew
|
||||
# past the cap after the fact.
|
||||
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Gitea releases response advertised %d bytes — refusing",
|
||||
response.content_length,
|
||||
)
|
||||
return None
|
||||
raw = await response.read()
|
||||
if len(raw) > _MAX_BODY_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Gitea releases response exceeded %d bytes — refusing to parse",
|
||||
_MAX_BODY_BYTES,
|
||||
)
|
||||
return None
|
||||
import json
|
||||
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
_LOGGER.warning("Gitea releases fetch error: %s", err)
|
||||
return None
|
||||
except (ValueError, UnicodeDecodeError) as err:
|
||||
_LOGGER.warning("Gitea releases parse error: %s", err)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, list):
|
||||
return None
|
||||
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("draft"):
|
||||
continue
|
||||
if entry.get("prerelease") and not include_prereleases:
|
||||
continue
|
||||
return _to_release_info(entry)
|
||||
return None
|
||||
|
||||
async def test(self) -> ReleaseTestResult:
|
||||
# Validate URL first so the "test" button surfaces an SSRF rejection
|
||||
# to the operator rather than silently returning "unreachable".
|
||||
try:
|
||||
await avalidate_outbound_url(self._endpoint)
|
||||
except UnsafeURLError:
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
|
||||
|
||||
try:
|
||||
async with self._session.get(
|
||||
self._endpoint,
|
||||
params={"limit": "1", "page": "1", "draft": "false"},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.HTTP_ERROR.value}
|
||||
# Enforce a size cap without trusting chunked encoding: read
|
||||
# the whole body (aiohttp buffers it) but reject anything that
|
||||
# advertised more than the cap up front, and bail if it grew
|
||||
# past the cap after the fact.
|
||||
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
|
||||
_LOGGER.warning(
|
||||
"Gitea releases response advertised %d bytes — refusing",
|
||||
response.content_length,
|
||||
)
|
||||
return None
|
||||
raw = await response.read()
|
||||
if len(raw) > _MAX_BODY_BYTES:
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
|
||||
import json
|
||||
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.NETWORK_ERROR.value}
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
|
||||
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.NO_RELEASE_FOUND.value}
|
||||
first = payload[0]
|
||||
if not isinstance(first, dict):
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
|
||||
return {"ok": True, "info": _to_release_info(first), "error": None}
|
||||
|
||||
|
||||
def _to_release_info(entry: dict) -> ReleaseInfo:
|
||||
tag = str(entry.get("tag_name") or "").strip()
|
||||
return ReleaseInfo(
|
||||
tag=tag,
|
||||
version=normalise_version(tag),
|
||||
name=entry.get("name") or None,
|
||||
body=entry.get("body") or None,
|
||||
url=entry.get("html_url") or None,
|
||||
published_at=entry.get("published_at") or entry.get("created_at") or None,
|
||||
prerelease=bool(entry.get("prerelease", False)),
|
||||
draft=bool(entry.get("draft", False)),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""GitHub release provider stub.
|
||||
|
||||
Reserved so the registry advertises the option and the frontend can render the
|
||||
provider toggle without a follow-up backend release. The full implementation
|
||||
will mirror :class:`GiteaReleaseProvider` against
|
||||
``api.github.com/repos/{owner}/{repo}/releases``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .base import ReleaseErrorCode, ReleaseInfo, ReleaseProviderKind, ReleaseTestResult
|
||||
|
||||
|
||||
class GitHubReleaseProvider:
|
||||
"""Not yet implemented — placeholder so the registry is forward-compatible."""
|
||||
|
||||
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITHUB
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, repo: str) -> None:
|
||||
self._session = session
|
||||
self._repo = repo
|
||||
|
||||
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
|
||||
# Soft-fail rather than raise — `run_check` already catches
|
||||
# NotImplementedError but a None return keeps the persisted
|
||||
# `release_error` taxonomy clean (NOT_IMPLEMENTED, not "not impl…").
|
||||
return None
|
||||
|
||||
async def test(self) -> ReleaseTestResult:
|
||||
return {"ok": False, "info": None, "error": ReleaseErrorCode.NOT_IMPLEMENTED.value}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Factory for release providers — single entry point for callers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ReleaseProvider, ReleaseProviderKind, is_valid_repo
|
||||
from .gitea import GiteaReleaseProvider
|
||||
from .github import GitHubReleaseProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
|
||||
def build_release_provider(
|
||||
kind: str | ReleaseProviderKind,
|
||||
*,
|
||||
session: aiohttp.ClientSession,
|
||||
url: str = "",
|
||||
repo: str = "",
|
||||
) -> ReleaseProvider | None:
|
||||
"""Build a release provider for the given kind.
|
||||
|
||||
Returns ``None`` when disabled or when required configuration is missing
|
||||
or unsafe (invalid repo format, empty URL) — callers treat the absence as
|
||||
"no checks performed" without branching on the kind string everywhere.
|
||||
"""
|
||||
|
||||
try:
|
||||
normalised = (
|
||||
ReleaseProviderKind(kind)
|
||||
if not isinstance(kind, ReleaseProviderKind)
|
||||
else kind
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if normalised is ReleaseProviderKind.DISABLED:
|
||||
return None
|
||||
if normalised is ReleaseProviderKind.GITEA:
|
||||
if not url or not is_valid_repo(repo):
|
||||
return None
|
||||
try:
|
||||
return GiteaReleaseProvider(session=session, url=url, repo=repo)
|
||||
except ValueError:
|
||||
return None
|
||||
if normalised is ReleaseProviderKind.GITHUB:
|
||||
if not is_valid_repo(repo):
|
||||
return None
|
||||
return GitHubReleaseProvider(session=session, repo=repo)
|
||||
return None
|
||||
+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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user