Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a8f1e71c | |||
| 2d59a5b994 | |||
| a20635a657 | |||
| d7c48b06ee | |||
| 66f152ef2c | |||
| faaaa39f8a | |||
| 8651767112 | |||
| 10d30fc956 | |||
| 22127e2a59 | |||
| 90f958bdc6 | |||
| dec0839853 | |||
| dfd7329177 | |||
| ba199f24bd | |||
| bb5afcc222 | |||
| 4335036c22 | |||
| 5d41a39406 | |||
| 6229bf9b74 | |||
| a666bad0c4 | |||
| bede928a3f | |||
| 87cb33cffe | |||
| 757271dadf | |||
| 73b046f7a2 | |||
| b170c2b792 | |||
| 35a3008896 | |||
| 632e4c1aa3 | |||
| 0eb899afb9 | |||
| 5bd63a2191 |
@@ -0,0 +1,177 @@
|
|||||||
|
# Feature Backlog
|
||||||
|
|
||||||
|
Curated feature ideas, narrowed from a brainstorming pass on 2026-05-13.
|
||||||
|
Order is **rough sequencing preference**, not strict priority — adjust as we go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quiet Hours — close the gaps in the existing system
|
||||||
|
|
||||||
|
**Reality check (verified 2026-05-13).** Quiet hours are already shipped under
|
||||||
|
the "deferred dispatch" name in v0.8.0. The pipeline lives at
|
||||||
|
`packages/server/src/notify_bridge_server/services/deferred_dispatch.py` with
|
||||||
|
helpers in `dispatch_helpers.py` and tests in
|
||||||
|
`tests/test_deferred_dispatch.py`. What exists:
|
||||||
|
|
||||||
|
- Per-tracking-config window: `tracking_config.quiet_hours_enabled`,
|
||||||
|
`quiet_hours_start`, `quiet_hours_end`.
|
||||||
|
- Per-link override: `notification_tracker_target.quiet_hours_start`,
|
||||||
|
`quiet_hours_end`.
|
||||||
|
- Smart coalescing (asset add + asset remove during a window cancels each
|
||||||
|
other out, set-union merge for repeated adds).
|
||||||
|
- Post-window drain via APScheduler one-shot date jobs.
|
||||||
|
- Wall-clock event types (`scheduled_message`) drop instead of deferring.
|
||||||
|
- Frontend status surface: `deferred`, `deferred_then_dropped`,
|
||||||
|
`deferred_then_failed`, with `deferred_until` and `deferred_for_seconds`
|
||||||
|
fields exposed in the event log.
|
||||||
|
|
||||||
|
**What's NOT there (the actual gaps):**
|
||||||
|
|
||||||
|
| Gap | Sketch |
|
||||||
|
| --- | --- |
|
||||||
|
| **Target-level windows** | Today, hours bind to the *watcher* (tracking config / link). Users naturally think of DND at the *destination* ("don't ping my phone at night, regardless of source"). New column on `notification_target` + dispatcher gate. |
|
||||||
|
| **Multiple windows per row** | Today is a single HH:MM range. Real schedules want weekday-evening + weekend-all-day. JSON list of windows. |
|
||||||
|
| **Days-of-week** | Same window every day. Need `days: ["mon", "tue", ...]` filter per window. |
|
||||||
|
| **Per-window timezone** | Uses the global app TZ. Multi-traveller / multi-target setups want per-window TZ. |
|
||||||
|
| **Silent mode** | Modes today are defer-or-drop. Telegram `disable_notification=true` ("send but don't ring") is a third useful mode. |
|
||||||
|
| **Per-receiver windows** | One bot → multiple chats, each potentially with its own DND. Today it's all-or-nothing per target. |
|
||||||
|
|
||||||
|
**Recommended cut for v1 of "extend quiet hours":**
|
||||||
|
|
||||||
|
- Add target-level quiet hours (new column `notification_target.quiet_hours_json`
|
||||||
|
= list of `{days, start, end, mode, tz}`).
|
||||||
|
- Modes: `drop`, `defer`, `silent`. `defer` reuses the existing
|
||||||
|
deferred-dispatch pipeline (just changes who decides). `silent` maps to
|
||||||
|
`disable_notification=true` for Telegram; other targets fall through to
|
||||||
|
normal send (or we treat `silent` as `defer` for non-Telegram targets — TBD).
|
||||||
|
- Dispatcher precedence: target window wins over link/tracking-config window
|
||||||
|
when both are configured. Document this explicitly.
|
||||||
|
- Frontend: new "Quiet hours" fieldset in the target editor (Aurora cassette
|
||||||
|
style). Reuses Timezone picker; new day-picker chip row.
|
||||||
|
- Skip days-of-week + multi-window in v1 if scope grows — ship the target-level
|
||||||
|
cut first, then iterate.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
|
||||||
|
- How exactly do target / link / tracking-config windows combine? Proposal:
|
||||||
|
any window covering "now" wins (drop > defer > silent precedence).
|
||||||
|
- Should `silent` for non-Telegram targets degrade to normal send or to
|
||||||
|
defer? Defer is the safer default.
|
||||||
|
- Does the event log need a new status (`silenced` / `dropped_by_target_qh`)
|
||||||
|
to make precedence visible?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Immich Smart Actions (expand beyond Auto-Organize)
|
||||||
|
|
||||||
|
**What.** Extend the existing Smart Actions pattern (currently:
|
||||||
|
**Immich Auto-Organize**) with more rule-driven actions against the Immich API.
|
||||||
|
|
||||||
|
**Why.** Auto-Organize already proves the descriptor → rule editor → executor
|
||||||
|
pipeline. Adding actions is mostly authoring new executors + small UI rule
|
||||||
|
shapes, not new infra.
|
||||||
|
|
||||||
|
**Candidates (pick in this order).**
|
||||||
|
|
||||||
|
1. **Auto-favorite by person** — when an asset is detected containing person
|
||||||
|
X (or any of a set), mark it favorite.
|
||||||
|
2. **Auto-archive by age / album** — assets older than N days in a given
|
||||||
|
album get archived. Pair with a "dry-run shows count" UX like
|
||||||
|
Auto-Organize already has.
|
||||||
|
3. **Duplicate cluster nudge** — periodically run Immich's duplicate API and
|
||||||
|
send a digest notification with inline buttons ("review", "ignore for 30d").
|
||||||
|
Depends on inline-button work (see backlog item 4 dependencies).
|
||||||
|
4. **Share-link rotation** — for an album, regenerate the share link every N
|
||||||
|
days; notify with the new URL.
|
||||||
|
5. **Pending-delete review** — push a weekly digest of trash contents before
|
||||||
|
Immich's auto-purge fires.
|
||||||
|
|
||||||
|
**Shape.**
|
||||||
|
|
||||||
|
- Reuse the existing **action descriptor** layer
|
||||||
|
(`packages/core/src/notify_bridge_core/providers/actions.py`,
|
||||||
|
`action_executor.py`) and the frontend rule editor used by Auto-Organize.
|
||||||
|
- Each new action = (a) executor in core, (b) rule schema in the descriptor,
|
||||||
|
(c) frontend descriptor extension for the rule editor fields.
|
||||||
|
- Persist as `provider_actions` rows (already exists for Auto-Organize) with
|
||||||
|
a discriminator + JSON config.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
|
||||||
|
- Does "auto-favorite by person" need a confirmation queue or run silently?
|
||||||
|
Default to silent + event_log entry.
|
||||||
|
- How do we surface "this action moved/changed X assets" in the dashboard?
|
||||||
|
Probably a per-action stat tile on the provider detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Home Assistant Provider
|
||||||
|
|
||||||
|
**Full plan:** [feature-home-assistant.md](feature-home-assistant.md).
|
||||||
|
|
||||||
|
**One-line summary.** New WebSocket-based service provider with a 3-phase
|
||||||
|
ship: subscribe + dispatch (Phase 1), bot commands (Phase 2), HA service
|
||||||
|
calls as Smart Actions (Phase 3). Chosen over webhook ingest because
|
||||||
|
Phases 2 + 3 force a long-lived API connection anyway; consolidating on WS
|
||||||
|
avoids a refactor.
|
||||||
|
|
||||||
|
**Status:** planned, not started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Block-Based Template Builder
|
||||||
|
|
||||||
|
**What.** A visual, drag-and-drop builder for notification and command
|
||||||
|
templates that compiles down to Jinja2. Lives alongside (not instead of) the
|
||||||
|
current `JinjaEditor`. Author can flip between views.
|
||||||
|
|
||||||
|
**Why.** The current Jinja editor is powerful but unforgiving. A block UI
|
||||||
|
lowers the floor for new users and provides a discovery surface for the
|
||||||
|
variables documented in `template_configs.py`.
|
||||||
|
|
||||||
|
**Shape.**
|
||||||
|
|
||||||
|
- Frontend-only feature for v1 — compiles to the same Jinja strings the
|
||||||
|
backend already accepts.
|
||||||
|
- Blocks: `Text`, `Variable`, `If`, `For`, `Link`, `Image`, `Icon`, `Caption`,
|
||||||
|
`Group` (HTML span/group). Each block knows its serialized Jinja
|
||||||
|
representation.
|
||||||
|
- Round-trip: variables, simple `{% if %}` / `{% for %}` blocks, and string
|
||||||
|
literals parse back to blocks; arbitrary Jinja stays in a "Raw" block that
|
||||||
|
the user can edit as text.
|
||||||
|
- Variable picker reads `get_template_variables(provider_type, slot)`. This is
|
||||||
|
the same data already shown in the template-help panel.
|
||||||
|
- Preview pane unchanged — reuses `services/sample_context.py` server
|
||||||
|
rendering.
|
||||||
|
- Toggle in the template editor: **Visual / Code**.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
|
||||||
|
- Round-tripping arbitrary Jinja is hard. v1: parseable subset → blocks,
|
||||||
|
anything else → single Raw block. Show a banner explaining.
|
||||||
|
- Locale handling: same compiled Jinja, just authored per locale tab.
|
||||||
|
- Do we want a marketplace of pre-built block compositions? Out of scope for
|
||||||
|
v1 — bundle import/export is a separate backlog item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Sequencing
|
||||||
|
|
||||||
|
1. **Quiet Hours per Target** — small, isolated, immediate user value.
|
||||||
|
2. **Immich Smart Actions** — incremental on existing pattern; ship one
|
||||||
|
action at a time (start with auto-favorite by person).
|
||||||
|
3. **Home Assistant Provider** — multi-file, follows new-provider checklist;
|
||||||
|
biggest user-base expansion.
|
||||||
|
4. **Block-Based Template Builder** — largest frontend lift; benefits from
|
||||||
|
the variable-doc work that the other features will exercise.
|
||||||
|
|
||||||
|
Dependencies are loose — 1 and 2 are independent of 3 and 4. The block
|
||||||
|
builder pairs nicely with Home Assistant because HA's rich context surfaces
|
||||||
|
the value of an easier authoring UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision log
|
||||||
|
|
||||||
|
- **2026-05-13** — Backlog seeded with these four items selected from a
|
||||||
|
broader brainstorm. Not started.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Home Assistant Provider — Implementation Plan
|
||||||
|
|
||||||
|
> Status: **planned, not started**. Sequencing: third item on the backlog
|
||||||
|
> (see [feature-backlog.md](feature-backlog.md)).
|
||||||
|
> Last updated: 2026-05-13.
|
||||||
|
|
||||||
|
## Decision: WebSocket subscription, not webhook
|
||||||
|
|
||||||
|
We considered three ingest modes (webhook automation, WebSocket subscription,
|
||||||
|
hybrid). The WebSocket route is chosen as the architectural foundation because
|
||||||
|
the medium-term roadmap forces it anyway:
|
||||||
|
|
||||||
|
| Phase | Capability | Needs API access? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Subscribe to events, emit notifications | Read (event stream) |
|
||||||
|
| 2 | Bot commands (`/state`, `/entities`, `/areas`) | Read (REST or WS get_states) |
|
||||||
|
| 3 | Smart Actions (`light.turn_on`, scene activation) | Write (call_service) |
|
||||||
|
|
||||||
|
A webhook-only Phase 1 would still need a REST client by Phase 2 and a write
|
||||||
|
path by Phase 3 — net result is two client implementations + one event
|
||||||
|
pipeline refactor. WebSocket consolidates all three phases on one connection.
|
||||||
|
|
||||||
|
**Tradeoff (be honest):** WebSocket introduces a long-lived-connection pattern
|
||||||
|
this codebase does not have yet. Reconnect logic, missed-events-on-restart
|
||||||
|
gap, and a new shape on the `ServiceProvider` ABC are real costs. Phase 1 is
|
||||||
|
**not** shippable in one short session — plan for a multi-session build.
|
||||||
|
|
||||||
|
## Provider abstraction extension
|
||||||
|
|
||||||
|
The current `ServiceProvider` ABC
|
||||||
|
([packages/core/src/notify_bridge_core/providers/base.py](../../packages/core/src/notify_bridge_core/providers/base.py))
|
||||||
|
is poll-oriented: every provider implements `poll(collection_ids, state) →
|
||||||
|
(events, new_state)`. Webhook providers (Gitea, Planka, Webhook) satisfy this
|
||||||
|
by no-op'ing `poll` and shoving events in via `api/webhooks.py` instead.
|
||||||
|
|
||||||
|
Home Assistant fits neither cleanly. The plan:
|
||||||
|
|
||||||
|
1. Add an **optional** `async subscribe(emit) → None` method on the base ABC.
|
||||||
|
Default implementation raises `NotImplementedError`. Polling providers do
|
||||||
|
not override it. The scheduler / lifecycle layer (currently `services/watcher.py`)
|
||||||
|
gains a "subscription manager" branch that, for any provider whose class
|
||||||
|
overrides `subscribe`, starts a long-lived task instead of registering
|
||||||
|
a polling job.
|
||||||
|
2. `emit` is a callback `(event: ServiceEvent) → None` provided by the
|
||||||
|
subscription manager — it routes events to the dispatcher exactly like the
|
||||||
|
webhook handler does today. Keeping the dispatch path unchanged is the
|
||||||
|
point of this design.
|
||||||
|
3. Reconnect lives **inside** `subscribe`: the method is expected to be a
|
||||||
|
`while not cancelled: try connect; on drop, sleep with backoff, retry`
|
||||||
|
loop. The manager cancels the task on shutdown via the cooperative cancel
|
||||||
|
token used elsewhere.
|
||||||
|
|
||||||
|
This is a small, additive change to one ABC. No existing provider is
|
||||||
|
modified.
|
||||||
|
|
||||||
|
## Phase 1 — Subscribe + Dispatch (MVP)
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Long-lived WebSocket connection to HA, authenticated with a long-lived
|
||||||
|
access token.
|
||||||
|
- Subscribe to the event bus with optional `event_type` filter (defaults to
|
||||||
|
`state_changed`).
|
||||||
|
- Translate HA events into `ServiceEvent` and dispatch via the existing
|
||||||
|
pipeline. Notifications go out exactly as they do today for any other
|
||||||
|
provider.
|
||||||
|
- Filter UI: entity-id glob list, domain allowlist (e.g. `light.*`,
|
||||||
|
`binary_sensor.*`), event-type allowlist. **Hard-required** to avoid the HA
|
||||||
|
firehose drowning the bridge.
|
||||||
|
- Connection test + entity listing via WS `get_states` (no REST client yet —
|
||||||
|
WS gives us both subscribe and read).
|
||||||
|
|
||||||
|
### Out of scope for Phase 1
|
||||||
|
|
||||||
|
- Bot commands → Phase 2.
|
||||||
|
- Service calls → Phase 3.
|
||||||
|
- Replay of events missed during disconnect (HA does not support this; we
|
||||||
|
document the gap and surface "reconnected after N seconds" in the event
|
||||||
|
log).
|
||||||
|
- Webhook-style ingestion (path-embedded token webhook receiver). If a user
|
||||||
|
prefers webhooks, we add it later as a second ingestion mode on the same
|
||||||
|
provider — out of scope for v1.
|
||||||
|
|
||||||
|
### Event types (v1)
|
||||||
|
|
||||||
|
| HA event | ServiceEvent type | Notification slot |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `state_changed` | `ha_state_changed` | `message_state_changed` |
|
||||||
|
| `automation_triggered` | `ha_automation_triggered` | `message_automation_triggered` |
|
||||||
|
| `call_service` | `ha_service_called` | `message_service_called` |
|
||||||
|
| (custom event types) | `ha_event_fired` | `message_event_fired` |
|
||||||
|
|
||||||
|
Default tracking config enables `state_changed` only — the others are loud
|
||||||
|
and opt-in.
|
||||||
|
|
||||||
|
### Context variables exposed to templates
|
||||||
|
|
||||||
|
Pulled directly from HA's `state_changed` payload, normalized:
|
||||||
|
|
||||||
|
- `entity_id` — `light.kitchen`
|
||||||
|
- `friendly_name` — `attributes.friendly_name` or fallback to `entity_id`
|
||||||
|
- `domain` — derived from `entity_id` before the dot
|
||||||
|
- `old_state` — `from_state.state`
|
||||||
|
- `new_state` — `to_state.state`
|
||||||
|
- `attributes` — dict of new-state attributes (raw)
|
||||||
|
- `device_class` — `attributes.device_class` if present
|
||||||
|
- `area` — `attributes.area_id` if present (best effort; only set if HA
|
||||||
|
exposes it via the area registry, which costs a `get_registry` WS call —
|
||||||
|
see "Open questions")
|
||||||
|
- `last_changed`, `last_updated` — ISO timestamps
|
||||||
|
- For non-`state_changed` events: `event_type`, `event_data` (full dict)
|
||||||
|
|
||||||
|
### File touch map (Phase 1)
|
||||||
|
|
||||||
|
**Core** (`packages/core/src/notify_bridge_core/`)
|
||||||
|
|
||||||
|
| Path | Action | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `providers/base.py` | Modify | Add optional `subscribe(emit)` ABC method (default `NotImplementedError`); add `HOME_ASSISTANT = "home_assistant"` to `ServiceProviderType` |
|
||||||
|
| `providers/capabilities.py` | Modify | Add `HOME_ASSISTANT_CAPABILITIES` + register |
|
||||||
|
| `providers/home_assistant/__init__.py` | Create | Export + register template variables |
|
||||||
|
| `providers/home_assistant/client.py` | Create | WebSocket client (auth, subscribe, get_states, call_service stub) |
|
||||||
|
| `providers/home_assistant/event_parser.py` | Create | HA event dict → `ServiceEvent` |
|
||||||
|
| `providers/home_assistant/provider.py` | Create | Class with `connect`, `disconnect`, `subscribe`, `list_collections` (entity list), `get_available_variables`, `get_provider_config_schema`, `test_connection`. `poll` raises NotImplementedError. |
|
||||||
|
| `templates/defaults/en/home_assistant_*.jinja2` | Create | 4 slot templates |
|
||||||
|
| `templates/defaults/ru/home_assistant_*.jinja2` | Create | 4 slot templates |
|
||||||
|
| `templates/defaults/loader.py` | Modify | Add to `PROVIDER_SLOT_FILE_MAP` |
|
||||||
|
| `templates/command_defaults/loader.py` | Modify | Stub entry — empty commands list for now |
|
||||||
|
| `templates/context.py` | Modify | HA context builder |
|
||||||
|
| `templates/validator.py` | Modify | Whitelist HA variable names |
|
||||||
|
|
||||||
|
**Server** (`packages/server/src/notify_bridge_server/`)
|
||||||
|
|
||||||
|
| Path | Action | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `services/watcher.py` *(or scheduler / lifecycle module that hosts polling)* | Modify | Add subscription-manager branch — for providers whose class overrides `subscribe`, start/stop long-running task instead of polling |
|
||||||
|
| `services/scheduler.py` | Verify | Confirm we cancel HA subscription on shutdown (graceful_shutdown_seconds path) |
|
||||||
|
| `api/template_configs.py` | Modify | `get_template_variables()` entry |
|
||||||
|
| `api/command_template_configs.py` | Modify | Sample ctx (minimal for Phase 1 — no commands) |
|
||||||
|
| `services/sample_context.py` | Modify | `_SAMPLE_CONTEXT["home_assistant"]` |
|
||||||
|
| `database/seeds.py` | Modify | Seed notification templates + default tracking config |
|
||||||
|
|
||||||
|
**Frontend** (`frontend/src/`)
|
||||||
|
|
||||||
|
| Path | Action | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `lib/providers/home-assistant.ts` | Create | Descriptor per CLAUDE.md rule 11 |
|
||||||
|
| `lib/providers/index.ts` | Modify | Register descriptor |
|
||||||
|
| `lib/locales/en.json` | Modify | `providers.typeHomeAssistant`, `gridDesc.providerHomeAssistant` |
|
||||||
|
| `lib/locales/ru.json` | Modify | Same |
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
| Path | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `packages/core/tests/providers/test_home_assistant_parser.py` | Create — HA payload → `ServiceEvent` |
|
||||||
|
| `packages/core/tests/providers/test_home_assistant_client.py` | Create — WS auth, subscribe, reconnect (use a fake server) |
|
||||||
|
| `packages/server/tests/test_home_assistant_subscription.py` | Create — subscription manager lifecycle, event flows through dispatcher |
|
||||||
|
|
||||||
|
### Frontend descriptor essentials
|
||||||
|
|
||||||
|
```text
|
||||||
|
type: "home_assistant"
|
||||||
|
defaultName: "Home Assistant"
|
||||||
|
icon: "home" (consider Lucide icon; HA logo if a custom asset exists)
|
||||||
|
hasUrl: true // base URL of HA (used to derive WS URL)
|
||||||
|
configFields:
|
||||||
|
- url: http(s)://homeassistant.local:8123
|
||||||
|
- access_token: long-lived access token (required)
|
||||||
|
- allowed_event_types: comma-separated, defaults to "state_changed"
|
||||||
|
eventFields: 4 checkboxes (state_changed, automation_triggered,
|
||||||
|
call_service, event_fired)
|
||||||
|
extraTrackingFields:
|
||||||
|
- entity_glob: tag input ("light.*", "binary_sensor.*_motion")
|
||||||
|
- domain_allowlist: tag input
|
||||||
|
collectionMeta: { label: "Entities", icon: "..." }
|
||||||
|
webhookBased: false // we are NOT webhook based
|
||||||
|
```
|
||||||
|
|
||||||
|
WS URL is derived: `wss://{host}/api/websocket` (or `ws://` for plain http
|
||||||
|
HA). Document this in the UI hint.
|
||||||
|
|
||||||
|
### Auth model
|
||||||
|
|
||||||
|
- **Long-lived access token** from HA (Profile → Long-Lived Access Tokens).
|
||||||
|
- Stored encrypted at rest via the same path the other providers use for
|
||||||
|
secrets (the bridge already has a secret-encryption helper — verify the
|
||||||
|
exact module name during implementation).
|
||||||
|
- WS auth handshake: connect → server sends `auth_required` → client sends
|
||||||
|
`{type: "auth", access_token: "..."}` → server replies `auth_ok` or
|
||||||
|
`auth_invalid`.
|
||||||
|
|
||||||
|
### Risks / open questions (Phase 1)
|
||||||
|
|
||||||
|
1. **Reconnect strategy.** Exponential backoff capped at 60s, jittered.
|
||||||
|
On reconnect, log a `connection_restored_after` event so the UI can
|
||||||
|
surface the gap. Document that HA does not support event replay.
|
||||||
|
2. **Area registry.** Pulling `area_id` for entities requires a separate
|
||||||
|
`config/area_registry/list` WS call. Decision needed: fetch once on
|
||||||
|
connect and cache, refetch on `area_registry_updated` event, or skip
|
||||||
|
`area` from the context entirely in v1. Recommendation: fetch on
|
||||||
|
connect, refetch on `area_registry_updated`, skip if it fails (best-effort).
|
||||||
|
3. **TLS verification for self-signed HA.** Homelab users often have
|
||||||
|
self-signed certs. Need a `verify_tls: bool` config field (default true)
|
||||||
|
and a clear warning when disabled. Same pattern as
|
||||||
|
`NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` for the SSRF case.
|
||||||
|
4. **Backpressure.** HA's `state_changed` can fire hundreds of events per
|
||||||
|
minute in a busy install. The subscription manager must drop or coalesce
|
||||||
|
if the dispatcher backlog grows beyond a threshold. Cheapest cut: a
|
||||||
|
bounded `asyncio.Queue` between WS receiver and dispatch — `put_nowait`
|
||||||
|
with overflow counter visible in the event log.
|
||||||
|
5. **Entity filter precedence.** Tracking-config has `collection_ids`
|
||||||
|
(entity_id list) and we want `entity_glob` + `domain_allowlist`. Decision:
|
||||||
|
if both `collection_ids` and globs are set, union them (any match passes).
|
||||||
|
Documented prominently in the tracker UI.
|
||||||
|
6. **Library choice.** `hass-client` is a Python WS client maintained by the
|
||||||
|
HA community; alternative is rolling our own with `websockets`. The
|
||||||
|
latter is ~150 LOC and has no external dependency surface. Recommendation:
|
||||||
|
roll our own. Re-evaluate if Phase 3 needs registry-aware service calls.
|
||||||
|
|
||||||
|
## Phase 2 — Bot Commands
|
||||||
|
|
||||||
|
Adds Telegram bot commands for HA tracking configs.
|
||||||
|
|
||||||
|
- `/status` — connection status, subscribed event count
|
||||||
|
- `/entities <glob>` — list matching entities + current state
|
||||||
|
- `/state <entity_id>` — full state + attributes for one entity
|
||||||
|
- `/areas` — area registry summary
|
||||||
|
- `/help`
|
||||||
|
|
||||||
|
These use the existing WS connection (no new client) via WS commands
|
||||||
|
`get_states`, `config/area_registry/list`. Template slots and command
|
||||||
|
template configs follow the same pattern as Gitea/Planka — see
|
||||||
|
[CLAUDE.md](../../CLAUDE.md) rule 7 / rule 11 for the full set of locations
|
||||||
|
that must be updated.
|
||||||
|
|
||||||
|
Out-of-scope for Phase 2: any command that mutates HA state.
|
||||||
|
|
||||||
|
## Phase 3 — Smart Actions (Service Calls)
|
||||||
|
|
||||||
|
A new action descriptor in the existing Smart Actions framework
|
||||||
|
([packages/core/src/notify_bridge_core/providers/actions.py](../../packages/core/src/notify_bridge_core/providers/actions.py)).
|
||||||
|
|
||||||
|
- Action type: `ha_call_service`
|
||||||
|
- Rule: trigger event → service call (e.g. "on motion event in
|
||||||
|
`binary_sensor.front_door` → call `light.turn_on` on `light.porch`")
|
||||||
|
- Executor uses the existing WS connection to send `call_service`.
|
||||||
|
|
||||||
|
This phase is gated behind explicit per-target authorization in the UI — HA
|
||||||
|
service calls can do anything the access token allows, including unlocking
|
||||||
|
doors. Default state: **disabled**, with a clear consent flow when enabling.
|
||||||
|
|
||||||
|
## Rough effort estimates
|
||||||
|
|
||||||
|
These are rough — sub-task discovery during Phase 1 will refine them.
|
||||||
|
|
||||||
|
| Phase | Estimate (focused work) |
|
||||||
|
| --- | --- |
|
||||||
|
| Phase 1 (subscribe + dispatch) | 2–3 sessions |
|
||||||
|
| Phase 2 (bot commands) | 1 session |
|
||||||
|
| Phase 3 (smart actions) | 1–2 sessions |
|
||||||
|
|
||||||
|
## When to start
|
||||||
|
|
||||||
|
Phase 1 work order, once you green-light it:
|
||||||
|
|
||||||
|
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
|
||||||
|
provider.
|
||||||
|
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
|
||||||
|
needed for these).
|
||||||
|
3. Subscription manager in `services/watcher.py` — integration test with the
|
||||||
|
fake provider from step 1.
|
||||||
|
4. Templates (en + ru), capabilities entry, validator whitelist.
|
||||||
|
5. Server: seeds, sample context, template_configs entry.
|
||||||
|
6. Frontend: descriptor, locale keys, i18n.
|
||||||
|
7. End-to-end smoke test against a real HA instance (homelab).
|
||||||
|
|
||||||
|
Backend restart cadence per the project rule: after **every** change in
|
||||||
|
`packages/server/` or `packages/core/`.
|
||||||
|
|
||||||
|
## Decision log
|
||||||
|
|
||||||
|
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
|
||||||
|
webhook for future-proofing toward Phases 2 + 3). Not started.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Debug Issue
|
||||||
|
description: Systematically debug issues using graph-powered code navigation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Issue
|
||||||
|
|
||||||
|
Use the knowledge graph to systematically trace and debug issues.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Use `semantic_search_nodes` to find code related to the issue.
|
||||||
|
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
|
||||||
|
3. Use `get_flow` to see full execution paths through suspected areas.
|
||||||
|
4. Run `detect_changes` to check if recent changes caused the issue.
|
||||||
|
5. Use `get_impact_radius` on suspected files to see what else is affected.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Check both callers and callees to understand the full context.
|
||||||
|
- Look at affected flows to find the entry point that triggers the bug.
|
||||||
|
- Recent changes are the most common source of new issues.
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||||
|
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||||
|
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: Explore Codebase
|
||||||
|
description: Navigate and understand codebase structure using the knowledge graph
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explore Codebase
|
||||||
|
|
||||||
|
Use the code-review-graph MCP tools to explore and understand the codebase.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Run `list_graph_stats` to see overall codebase metrics.
|
||||||
|
2. Run `get_architecture_overview` for high-level community structure.
|
||||||
|
3. Use `list_communities` to find major modules, then `get_community` for details.
|
||||||
|
4. Use `semantic_search_nodes` to find specific functions or classes.
|
||||||
|
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
|
||||||
|
6. Use `list_flows` and `get_flow` to understand execution paths.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Start broad (stats, architecture) then narrow down to specific areas.
|
||||||
|
- Use `children_of` on a file to see all its functions and classes.
|
||||||
|
- Use `find_large_functions` to identify complex code.
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||||
|
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||||
|
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: Refactor Safely
|
||||||
|
description: Plan and execute safe refactoring using dependency analysis
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactor Safely
|
||||||
|
|
||||||
|
Use the knowledge graph to plan and execute refactoring with confidence.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
|
||||||
|
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
|
||||||
|
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
|
||||||
|
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
|
||||||
|
5. After changes, run `detect_changes` to verify the refactoring impact.
|
||||||
|
|
||||||
|
### Safety Checks
|
||||||
|
|
||||||
|
- Always preview before applying (rename mode gives you an edit list).
|
||||||
|
- Check `get_impact_radius` before major refactors.
|
||||||
|
- Use `get_affected_flows` to ensure no critical paths are broken.
|
||||||
|
- Run `find_large_functions` to identify decomposition targets.
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||||
|
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||||
|
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Review Changes
|
||||||
|
description: Perform a structured code review using change detection and impact
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Changes
|
||||||
|
|
||||||
|
Perform a thorough, risk-aware code review using the knowledge graph.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Run `detect_changes` to get risk-scored change analysis.
|
||||||
|
2. Run `get_affected_flows` to find impacted execution paths.
|
||||||
|
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
|
||||||
|
4. Run `get_impact_radius` to understand the blast radius.
|
||||||
|
5. For any untested changes, suggest specific test cases.
|
||||||
|
|
||||||
|
### Output Format
|
||||||
|
|
||||||
|
Provide findings grouped by risk level (high/medium/low) with:
|
||||||
|
- What changed and why it matters
|
||||||
|
- Test coverage status
|
||||||
|
- Suggested improvements
|
||||||
|
- Overall merge recommendation
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||||
|
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||||
|
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"last_commit": "a31b1cba2a41229f6f6af9701477d24d15efbe9a",
|
"last_commit": "cfdafa9c2b49ea64496e9355d92337dbbb70db93",
|
||||||
"last_sync": "2026-04-21T00:00:00Z",
|
"last_sync": "2026-05-16T00:00:00Z",
|
||||||
"tracked_files": {
|
"tracked_files": {
|
||||||
"gitea-python-ci-cd.md": "sha256:61968058ec30cac954a3b7f9bde2a7db620618482d34e17568d432f680a3b333",
|
"gitea-python-ci-cd.md": "sha256:9f1f57e1b0d909143e20cb3f21ac9c4d75b45f2992ec002645540f94c4920851",
|
||||||
"gitea-release-workflow.md": "sha256:5eb64789fca062b2138ca7661b942c9fc9c304f63326844ff6f6724e7e05b08c"
|
"gitea-release-workflow.md": "sha256:5eb64789fca062b2138ca7661b942c9fc9c304f63326844ff6f6724e7e05b08c"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,58 @@ jobs:
|
|||||||
- name: Svelte check
|
- name: Svelte check
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run check || echo "::warning::svelte-check reported warnings"
|
npm run check
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Editable installs of packages/core + packages/server are extremely slow
|
||||||
|
# on the hosted runner — measured 4-6x slower than building wheels first
|
||||||
|
# because hatchling's editable hook re-resolves on every collection. We
|
||||||
|
# build wheels once into an isolated venv, then install them (and only
|
||||||
|
# the test deps). The venv isolation also prevents broken-wheel installs
|
||||||
|
# from leaking dist-info across runs on the persistent Gitea runner
|
||||||
|
# (pip can't uninstall a wheel that landed without a RECORD file). The
|
||||||
|
# wheels themselves are NOT cached because their hashes depend on every
|
||||||
|
# file under packages/ — invalidates on basically every PR. Pip's HTTP
|
||||||
|
# cache for the test deps is enough.
|
||||||
|
- name: Build wheels in isolated venv
|
||||||
|
run: |
|
||||||
|
python -m venv /tmp/venv
|
||||||
|
/tmp/venv/bin/pip install --upgrade pip build
|
||||||
|
mkdir -p /tmp/wheels
|
||||||
|
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
|
||||||
|
|
||||||
|
- name: Install backend + test deps
|
||||||
|
run: |
|
||||||
|
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
env:
|
||||||
|
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
|
||||||
|
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NOTIFY_BRIDGE_DEBUG: "false"
|
||||||
|
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
|
||||||
|
run: |
|
||||||
|
cd packages/server
|
||||||
|
/tmp/venv/bin/pytest tests --tb=short
|
||||||
|
|
||||||
build-image:
|
build-image:
|
||||||
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||||
needs: [test-frontend]
|
needs: [test-frontend, test-backend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -10,7 +10,43 @@ env:
|
|||||||
IMAGE_NAME: alexei.dolgolyov/notify-bridge
|
IMAGE_NAME: alexei.dolgolyov/notify-bridge
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Wheel-first strategy in an isolated venv — editable install is too slow,
|
||||||
|
# and a plain pip install into the toolcache Python leaks state across
|
||||||
|
# runs on the persistent Gitea runner (previous broken wheel installs
|
||||||
|
# leave dist-info dirs that pip can't uninstall: "uninstall-no-record-file").
|
||||||
|
- name: Build wheels in isolated venv
|
||||||
|
run: |
|
||||||
|
python -m venv /tmp/venv
|
||||||
|
/tmp/venv/bin/pip install --upgrade pip build
|
||||||
|
mkdir -p /tmp/wheels
|
||||||
|
/tmp/venv/bin/pip wheel --no-deps -w /tmp/wheels packages/core packages/server
|
||||||
|
|
||||||
|
- name: Install backend + test deps
|
||||||
|
run: |
|
||||||
|
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
env:
|
||||||
|
NOTIFY_BRIDGE_DATA_DIR: /tmp/nb-test-data
|
||||||
|
NOTIFY_BRIDGE_SECRET_KEY: ci-secret-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NOTIFY_BRIDGE_DEBUG: "false"
|
||||||
|
NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS: "http://localhost:8420"
|
||||||
|
run: |
|
||||||
|
cd packages/server
|
||||||
|
/tmp/venv/bin/pytest tests --tb=short
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
needs: [test-backend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
|
|||||||
@@ -56,3 +56,5 @@ frontend/.svelte-kit/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
# Added by code-review-graph
|
||||||
|
.code-review-graph/
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"code-review-graph": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"code-review-graph",
|
||||||
|
"serve"
|
||||||
|
],
|
||||||
|
"type": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,42 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
|||||||
- Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`)
|
- Notification preview sample: `packages/server/src/notify_bridge_server/services/sample_context.py` (`_SAMPLE_CONTEXT`)
|
||||||
- Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`)
|
- Command preview sample: `packages/server/src/notify_bridge_server/api/command_template_configs.py` (`sample_ctx` in `preview_raw`)
|
||||||
- Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py`
|
- Runtime validator whitelist: `packages/core/src/notify_bridge_core/templates/validator.py`
|
||||||
|
|
||||||
|
<!-- code-review-graph MCP tools -->
|
||||||
|
## MCP Tools: code-review-graph
|
||||||
|
|
||||||
|
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||||
|
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||||
|
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||||
|
you structural context (callers, dependents, test coverage) that file
|
||||||
|
scanning cannot.
|
||||||
|
|
||||||
|
### When to use graph tools FIRST
|
||||||
|
|
||||||
|
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||||
|
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||||
|
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||||
|
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||||
|
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||||
|
|
||||||
|
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||||
|
|
||||||
|
### Key Tools
|
||||||
|
|
||||||
|
| Tool | Use when |
|
||||||
|
|------|----------|
|
||||||
|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||||
|
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||||
|
| `get_impact_radius` | Understanding blast radius of a change |
|
||||||
|
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||||
|
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||||
|
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||||
|
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||||
|
| `refactor_tool` | Planning renames, finding dead code |
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. The graph auto-updates on file changes (via hooks).
|
||||||
|
2. Use `detect_changes` for code review.
|
||||||
|
3. Use `get_affected_flows` to understand impact.
|
||||||
|
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||||
|
|||||||
+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.
|
A generic bridge between service providers and notification targets.
|
||||||
|
|
||||||
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
|
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
|
||||||
notifications to configurable targets (Telegram, webhooks) using customizable templates.
|
and internal scheduler) for changes and dispatches notifications to configurable targets
|
||||||
|
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
|
||||||
|
|
||||||
## Architecture
|
## 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
|
- **Trackers** — Monitor specific collections within a provider for changes
|
||||||
- **Tracking Configs** — Define what events to watch for and scheduling rules
|
- **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
|
- **Template Configs** — Jinja2 templates that format notifications per provider type
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```text
|
||||||
packages/
|
packages/
|
||||||
core/ — Shared library: providers, models, notifications, templates
|
core/ — Shared library: providers, models, notifications, templates
|
||||||
server/ — FastAPI REST server with SQLite database
|
server/ — FastAPI REST server with SQLite database
|
||||||
@@ -31,6 +32,7 @@ docker run -d \
|
|||||||
-p 8420:8420 \
|
-p 8420:8420 \
|
||||||
-v notify-bridge-data:/data \
|
-v notify-bridge-data:/data \
|
||||||
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
|
-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
|
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
| -------- | -------- | ------- | ----------- |
|
| -------- | -------- | ------- | ----------- |
|
||||||
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
|
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
|
||||||
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
|
| `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. |
|
||||||
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
|
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
|
||||||
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
|
| `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
|
### Docker Compose
|
||||||
|
|
||||||
@@ -58,12 +107,50 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- notify-bridge-data:/data
|
- notify-bridge-data:/data
|
||||||
environment:
|
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:
|
volumes:
|
||||||
notify-bridge-data:
|
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)
|
## Quick Start (Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,4 +168,48 @@ npm run dev
|
|||||||
|
|
||||||
## Supported Providers
|
## 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.
|
||||||
|
|||||||
+36
-12
@@ -1,32 +1,56 @@
|
|||||||
# v0.6.5 (2026-04-28)
|
# v0.8.2 (2026-05-22)
|
||||||
|
|
||||||
UI polish across the redesign: command-template editing now groups slots into four labelled fieldsets that mirror the notification-template page, modal/popup scrolling no longer drags the page underneath, and Telegram's Discover Chats keeps the existing list visible with a smooth shimmer instead of blanking it to "Loading…".
|
A production-readiness hardening release that follows up on v0.8.1 with six isolated, low-risk fixes surfaced by a parallel full-codebase review (backend, frontend, security, performance, UI/UX, bugs+features). No breaking changes; no migrations required.
|
||||||
|
|
||||||
## User-facing changes
|
## User-facing changes
|
||||||
|
|
||||||
### Features
|
### Security
|
||||||
|
|
||||||
- **Command template slots grouped into 4 fieldsets:** the command-template configs page now mirrors the notification-template layout, splitting slots by name prefix into Command Responses, Error Messages (`rate_limited` / `no_results`), Command Descriptions (`desc_*`), and Usage Examples (`usage_*`). The language picker, reset-all button, and slot filter are hoisted above the groups so they apply across all fieldsets, and empty groups are hidden so providers without `usage_*` slots don't render an empty header ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
- **Provider `access_token` masked in API responses.** The provider GET endpoints were leaking plaintext credentials — most importantly Home Assistant long-lived tokens — in their JSON payloads. The field is now masked on read and dropped on edit when the `***` placeholder is sent back, so the UI can show "set" / "unset" without ever round-tripping the secret. Centralized through `PROVIDER_SECRET_FIELDS` so every call site stays in sync ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
- **Pre-auth resource-exhaustion amplifier closed on webhook ingest.** The Gitea provider used to read the 1 MiB request body before checking whether a secret was even configured or whether the request had a signature header — an unauthenticated client could force a body read on every hit. The generic-webhook bearer-token path had the same shape: body read before Authorization check. Both now bail out before consuming the body when the auth precondition fails ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- **Modal scroll chaining contained:** scrolling past the inner boundary of a modal or popup no longer scrolls the page underneath. `overscroll-behavior: contain` was added to every in-modal/popup scroll container — Modal body, `EntitySelect`, `MultiEntitySelect`, `IconPicker`, `IconGridSelect`, `SearchPalette`, and `TimezoneSelector` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
- **Home Assistant status-change events no longer silently lost.** `ha_status_changed` rows are written from `asyncio.create_task(...)`, but `create_task` only keeps a weak reference — the task was being garbage-collected before the row landed, so connection-flap events disappeared. The task handles are now held in a module-level set with a `done_callback` to release them on completion ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
- **Smoother Telegram Discover Chats refresh:** Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. The initial-load state (`chatsLoading`) is now split from the refresh state (`chatsRefreshing`); rows are keyed by `chat.id` with flip+fade animations, the list dims with a sweeping shimmer while the Discover button shows a spinning icon and a "Discovering chats…" label. Honors `prefers-reduced-motion` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
|
- **Telegram-webhook handler exceptions can no longer leak writes.** The catch-all error path in the Telegram inbound endpoint now rolls back the request's SQLAlchemy session before returning, so a handler crash mid-transaction cannot bleed uncommitted state into the next request on the same connection ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- **Toast notifications now announced by screen readers.** Added `role="region"` on the snackbar container plus per-toast `role` / `aria-live` / `aria-atomic` attributes, with a localized region name (`snackbar.region`) in both `en` and `ru` ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
- **Active sidebar link now has an accessible state.** `aria-current="page"` is now set on the matching nav item, so assistive tech can announce the active route ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development / Internal
|
## Development / Internal
|
||||||
|
|
||||||
### i18n
|
### Refactoring
|
||||||
|
|
||||||
- Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
|
- **Last `provider.type === 'immich'` check removed from components.** The action-rule editor's "Auto-organize" affordance now consumes a `supportsAutoOrganize` capability on `ProviderDescriptor` instead of branching on the provider type — bringing the rule editor under CLAUDE.md rule 8 (no provider-specific hardcoding in components) ([2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **Synced `.facts-sync.json` with `claude-code-facts@cfdafa9`.** Both previously pending suggestions (venv install for monorepos + hatchling METADATA workaround) were applied upstream; the local queue is empty ([a20635a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a20635a))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Known gaps (tracked for follow-up)
|
||||||
|
|
||||||
|
The full-codebase review surfaced more ship-blockers than this release fixes. Each of the items below needs more than a mechanical edit and is tracked in `.claude/reviews/README.md`:
|
||||||
|
|
||||||
|
- Secret encryption at rest
|
||||||
|
- JWT moved into an HTTP-only cookie
|
||||||
|
- Alembic adoption (currently `create_all`)
|
||||||
|
- Webhook delivery idempotency
|
||||||
|
- Deferred-dispatch crash window
|
||||||
|
- Persisted Telegram update watermark
|
||||||
|
- `bridge_self` counter lock
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
- [2d59a5b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2d59a5b) — `fix: production-readiness hardening from full-codebase review` (alexei.dolgolyov)
|
||||||
|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------|
|
- [a20635a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a20635a) — `chore: sync .facts-sync.json with claude-code-facts@cfdafa9` (alexei.dolgolyov)
|
||||||
| [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov |
|
|
||||||
| [9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e) | fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Generated
+16
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"version": "0.6.1",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"version": "0.6.1",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.40.0",
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/geist-mono": "^5.2.7",
|
"@fontsource/geist-mono": "^5.2.7",
|
||||||
"@fontsource/geist-sans": "^5.2.5",
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
@@ -607,6 +608,14 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/geist": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fontsource/dm-sans": {
|
"node_modules/@fontsource/dm-sans": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||||
@@ -2887,6 +2896,11 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@fontsource-variable/geist": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
|
||||||
|
},
|
||||||
"@fontsource/dm-sans": {
|
"@fontsource/dm-sans": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.5",
|
"version": "0.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.40.0",
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/geist-mono": "^5.2.7",
|
"@fontsource/geist-mono": "^5.2.7",
|
||||||
"@fontsource/geist-sans": "^5.2.5",
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
|
|||||||
+101
-6
@@ -1,11 +1,17 @@
|
|||||||
@import '@fontsource/geist-sans/300.css';
|
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
|
||||||
@import '@fontsource/geist-sans/400.css';
|
so RU and EN render in the same font instead of falling back to a
|
||||||
@import '@fontsource/geist-sans/500.css';
|
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
|
||||||
@import '@fontsource/geist-sans/600.css';
|
(latin-only) imports — see --font-sans below for the family rename. */
|
||||||
@import '@fontsource/geist-sans/700.css';
|
@import '@fontsource-variable/geist';
|
||||||
@import '@fontsource/geist-mono/400.css';
|
@import '@fontsource/geist-mono/400.css';
|
||||||
@import '@fontsource/geist-mono/500.css';
|
@import '@fontsource/geist-mono/500.css';
|
||||||
@import '@fontsource/geist-mono/600.css';
|
@import '@fontsource/geist-mono/600.css';
|
||||||
|
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
|
||||||
|
declarations so Russian text renders in Geist Mono instead of falling
|
||||||
|
back to Cascadia/Consolas. */
|
||||||
|
@import '@fontsource/geist-mono/cyrillic-400.css';
|
||||||
|
@import '@fontsource/geist-mono/cyrillic-500.css';
|
||||||
|
@import '@fontsource/geist-mono/cyrillic-600.css';
|
||||||
@import '@fontsource/newsreader/300-italic.css';
|
@import '@fontsource/newsreader/300-italic.css';
|
||||||
@import '@fontsource/newsreader/400.css';
|
@import '@fontsource/newsreader/400.css';
|
||||||
@import '@fontsource/newsreader/400-italic.css';
|
@import '@fontsource/newsreader/400-italic.css';
|
||||||
@@ -68,7 +74,7 @@
|
|||||||
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
|
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||||
--font-display: 'Newsreader', ui-serif, Georgia, serif;
|
--font-display: 'Newsreader', ui-serif, Georgia, serif;
|
||||||
|
|
||||||
@@ -371,6 +377,46 @@ button:focus-visible, a:focus-visible {
|
|||||||
.stagger-children > * {
|
.stagger-children > * {
|
||||||
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
|
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(1) { animation-delay: 0ms; }
|
||||||
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
||||||
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
||||||
@@ -459,3 +505,52 @@ button:focus-visible, a:focus-visible {
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared toggle switch — used by provider config forms, tracking-config
|
||||||
|
extraTrackingFields, and anywhere else we render a boolean field.
|
||||||
|
Kept global so adding a new ConfigField type='toggle' caller doesn't
|
||||||
|
need to copy the CSS into its scoped <style>. */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch .toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch .toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0.1875rem;
|
||||||
|
left: 0.1875rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-track {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-track::after {
|
||||||
|
transform: translateX(1.125rem);
|
||||||
|
background: var(--color-primary-foreground);
|
||||||
|
}
|
||||||
|
|||||||
+40
-8
@@ -2,8 +2,41 @@
|
|||||||
* API client with JWT auth for the Notify Bridge backend.
|
* API client with JWT auth for the Notify Bridge backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the API client decides to redirect the user to /login (after a
|
||||||
|
* terminal 401). Caller-side `try/catch` blocks can branch on
|
||||||
|
* `instanceof AuthRedirectError` to skip showing an "Unauthorized" snackbar
|
||||||
|
* — the redirect itself is the user-visible signal.
|
||||||
|
*/
|
||||||
|
export class AuthRedirectError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Unauthorized — redirecting to login');
|
||||||
|
this.name = 'AuthRedirectError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level dedupe — a burst of concurrent requests that all get 401 (e.g.
|
||||||
|
// the dashboard's parallel cache loads) should only schedule a single
|
||||||
|
// `goto('/login')` instead of stacking N navigations.
|
||||||
|
let _redirecting = false;
|
||||||
|
|
||||||
|
/** Centralised "send the user to /login" path used by both api() and fetchAuth(). */
|
||||||
|
function redirectToLogin(): void {
|
||||||
|
if (_redirecting) return;
|
||||||
|
_redirecting = true;
|
||||||
|
clearTokens();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// SvelteKit's goto() with replaceState avoids leaving the failed page
|
||||||
|
// in the back-stack (no "back-button to broken view" UX). We don't
|
||||||
|
// reset `_redirecting` — the page about to unmount makes it moot.
|
||||||
|
goto('/login', { replaceState: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalize a caught error to a user-safe message. */
|
/** Normalize a caught error to a user-safe message. */
|
||||||
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||||
if (err instanceof Error && err.message) return err.message;
|
if (err instanceof Error && err.message) return err.message;
|
||||||
@@ -129,11 +162,11 @@ export async function api<T = any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401 && token) {
|
if (res.status === 401 && token) {
|
||||||
clearTokens();
|
redirectToLogin();
|
||||||
if (typeof window !== 'undefined') {
|
// Tagged so the caller's catch can distinguish "we already showed
|
||||||
window.location.href = '/login';
|
// the user a redirect" from a real authorization failure they
|
||||||
}
|
// should snackbar.
|
||||||
throw new Error('Unauthorized');
|
throw new AuthRedirectError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
@@ -204,9 +237,8 @@ export async function fetchAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearTokens();
|
redirectToLogin();
|
||||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
throw new AuthRedirectError();
|
||||||
throw new ApiError('Unauthorized', 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
noneLabel = '—',
|
noneLabel = '—',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
open = $bindable(false),
|
||||||
|
showTrigger = true,
|
||||||
onselect,
|
onselect,
|
||||||
|
onclose,
|
||||||
}: {
|
}: {
|
||||||
items: EntityItem[];
|
items: EntityItem[];
|
||||||
value: string | number | null;
|
value: string | number | null;
|
||||||
@@ -29,10 +32,12 @@
|
|||||||
noneLabel?: string;
|
noneLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'sm' | 'default';
|
size?: 'sm' | 'default';
|
||||||
|
open?: boolean;
|
||||||
|
showTrigger?: boolean;
|
||||||
onselect?: (value: string | number | null) => void;
|
onselect?: (value: string | number | null) => void;
|
||||||
|
onclose?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let highlightIdx = $state(0);
|
let highlightIdx = $state(0);
|
||||||
let inputEl = $state<HTMLInputElement | undefined>();
|
let inputEl = $state<HTMLInputElement | undefined>();
|
||||||
@@ -52,24 +57,37 @@
|
|||||||
return [...result, ...matching];
|
return [...result, ...matching];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Focus input whenever the palette transitions to open (covers both internal
|
||||||
|
// trigger clicks and external programmatic opening via bind:open).
|
||||||
|
let wasOpen = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !wasOpen) {
|
||||||
|
query = '';
|
||||||
|
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
wasOpen = open;
|
||||||
|
});
|
||||||
|
|
||||||
function openPalette() {
|
function openPalette() {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
open = true;
|
open = true;
|
||||||
query = '';
|
|
||||||
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
|
|
||||||
requestAnimationFrame(() => inputEl?.focus());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when the user dismisses the palette (overlay click or ESC).
|
||||||
|
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
|
||||||
function closePalette() {
|
function closePalette() {
|
||||||
open = false;
|
open = false;
|
||||||
query = '';
|
query = '';
|
||||||
|
onclose?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectItem(item: EntityItem) {
|
function selectItem(item: EntityItem) {
|
||||||
if (item.disabled) return;
|
if (item.disabled) return;
|
||||||
value = item.value || null;
|
value = item.value || null;
|
||||||
onselect?.(value);
|
onselect?.(value);
|
||||||
closePalette();
|
open = false;
|
||||||
|
query = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -106,21 +124,23 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Trigger button -->
|
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
|
||||||
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
{#if showTrigger}
|
||||||
aria-expanded={open}
|
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
|
||||||
aria-haspopup="listbox"
|
aria-expanded={open}
|
||||||
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
|
aria-haspopup="listbox"
|
||||||
{#if selected}
|
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
|
||||||
{#if selected.icon}
|
{#if selected}
|
||||||
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
|
{#if selected.icon}
|
||||||
|
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
|
||||||
|
{/if}
|
||||||
|
<span class="es-trigger-label">{selected.label}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="es-trigger-label">{selected.label}</span>
|
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
||||||
{:else}
|
</button>
|
||||||
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
|
{/if}
|
||||||
{/if}
|
|
||||||
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
||||||
{#if open}
|
{#if open}
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { EventLog } from '$lib/types';
|
||||||
|
import { requestHighlight } from '$lib/highlight';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: EventLog | null;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
let { event, onclose }: Props = $props();
|
||||||
|
|
||||||
|
// Retain the last non-null event so the modal body stays populated
|
||||||
|
// while the close transition plays after the parent clears `event`.
|
||||||
|
let displayEvent = $state<EventLog | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (event) displayEvent = event;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
|
||||||
|
*
|
||||||
|
* Used by the deferred-dispatch lifecycle banner to render
|
||||||
|
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
|
||||||
|
* integer that the user has to mentally divide. Keeps two units so
|
||||||
|
* the magnitude reads correctly across hours-long quiet windows
|
||||||
|
* without becoming noisy for short ones. */
|
||||||
|
function humanDuration(totalSeconds: number): string {
|
||||||
|
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
|
||||||
|
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remMin = minutes % 60;
|
||||||
|
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const remHours = hours % 24;
|
||||||
|
return remHours ? `${days}d ${remHours}h` : `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an absolute ISO timestamp as a future-relative string.
|
||||||
|
*
|
||||||
|
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
|
||||||
|
* deferred-until banner shouldn't show a relative offset once the
|
||||||
|
* window has already ended (a follow-up event_log row marks delivery).
|
||||||
|
*/
|
||||||
|
function timeFromNow(iso: string | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
const target = new Date(iso).getTime();
|
||||||
|
const diff = Math.floor((target - Date.now()) / 1000);
|
||||||
|
if (diff <= 0) return '';
|
||||||
|
return humanDuration(diff);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
|
||||||
|
if (!issuer) return '';
|
||||||
|
if (issuer.username) return '@' + issuer.username;
|
||||||
|
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
|
||||||
|
if (name) return name;
|
||||||
|
if (issuer.id) return 'id ' + issuer.id;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a list page and highlight the specific entity card.
|
||||||
|
*
|
||||||
|
* The destination page calls ``highlightFromUrl()`` after data loads,
|
||||||
|
* which scrolls to and pulses the card with ``data-entity-id={id}``.
|
||||||
|
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
|
||||||
|
function openEntity(path: string, entityId: number | string | null | undefined) {
|
||||||
|
if (entityId != null) requestHighlight(entityId);
|
||||||
|
onclose();
|
||||||
|
goto(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = $derived(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
|
||||||
|
const issuerText = $derived(issuerLabel(issuer));
|
||||||
|
|
||||||
|
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
|
||||||
|
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
|
||||||
|
|
||||||
|
const detailsJson = $derived.by(() => {
|
||||||
|
if (!displayEvent?.details) return '';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(displayEvent.details, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(displayEvent.details);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
|
||||||
|
{#if displayEvent}
|
||||||
|
<div class="event-detail">
|
||||||
|
<!-- Subject + verb -->
|
||||||
|
<div class="hero-row">
|
||||||
|
<MdiIcon name="mdiBell" size={18} />
|
||||||
|
<div>
|
||||||
|
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span class="event-type">{displayEvent.event_type}</span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span>{fmtDateTime(displayEvent.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dispatch lifecycle (only when the event went through the
|
||||||
|
quiet-hours defer path). Rendered ABOVE the provenance grid
|
||||||
|
because timing of delivery is more interesting than the
|
||||||
|
bot/tracker names when the event is held back. -->
|
||||||
|
{#if displayEvent.details?.dispatch_status === 'deferred'}
|
||||||
|
<section class="lifecycle lifecycle--deferred">
|
||||||
|
<MdiIcon name="mdiPauseCircleOutline" size={18} />
|
||||||
|
<div class="lifecycle-body">
|
||||||
|
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
|
||||||
|
<div class="lifecycle-detail">
|
||||||
|
{t('events.lifecycle.heldUntil')}
|
||||||
|
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
|
||||||
|
{#if timeFromNow(displayEvent.details.deferred_until)}
|
||||||
|
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
|
||||||
|
<section class="lifecycle lifecycle--late">
|
||||||
|
<MdiIcon name="mdiClockCheckOutline" size={18} />
|
||||||
|
<div class="lifecycle-body">
|
||||||
|
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
|
||||||
|
{#if displayEvent.details.deferred_for_seconds != null}
|
||||||
|
<div class="lifecycle-detail">
|
||||||
|
{t('events.lifecycle.heldFor')}
|
||||||
|
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.details.original_event_log_id}
|
||||||
|
<div class="lifecycle-hint">
|
||||||
|
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
|
||||||
|
<section class="lifecycle lifecycle--dropped">
|
||||||
|
<MdiIcon name="mdiCloseCircleOutline" size={18} />
|
||||||
|
<div class="lifecycle-body">
|
||||||
|
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
|
||||||
|
{#if displayEvent.details.reason}
|
||||||
|
<div class="lifecycle-detail">
|
||||||
|
{t('events.lifecycle.reason')}:
|
||||||
|
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.details.original_event_log_id}
|
||||||
|
<div class="lifecycle-hint">
|
||||||
|
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
|
||||||
|
<section class="lifecycle lifecycle--dropped">
|
||||||
|
<MdiIcon name="mdiAlertCircleOutline" size={18} />
|
||||||
|
<div class="lifecycle-body">
|
||||||
|
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
|
||||||
|
{#if displayEvent.details.reason}
|
||||||
|
<div class="lifecycle-detail">
|
||||||
|
{t('events.lifecycle.reason')}:
|
||||||
|
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.details.original_event_log_id}
|
||||||
|
<div class="lifecycle-hint">
|
||||||
|
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
|
||||||
|
<section class="lifecycle lifecycle--dropped">
|
||||||
|
<MdiIcon name="mdiVolumeOff" size={18} />
|
||||||
|
<div class="lifecycle-body">
|
||||||
|
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
|
||||||
|
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Provenance grid -->
|
||||||
|
<dl class="provenance">
|
||||||
|
{#if displayEvent.bot_name}
|
||||||
|
<dt>{t('events.bot')}</dt>
|
||||||
|
<dd>{displayEvent.bot_name}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.collection_id && isCommand}
|
||||||
|
<dt>{t('events.chat')}</dt>
|
||||||
|
<dd class="font-mono">{displayEvent.collection_id}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if issuerText}
|
||||||
|
<dt>{t('events.issuer')}</dt>
|
||||||
|
<dd>
|
||||||
|
{issuerText}
|
||||||
|
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
|
||||||
|
</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.command_tracker_name}
|
||||||
|
<dt>{t('events.commandTracker')}</dt>
|
||||||
|
<dd>{displayEvent.command_tracker_name}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.tracker_name}
|
||||||
|
<dt>{t('events.tracker')}</dt>
|
||||||
|
<dd>{displayEvent.tracker_name}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.action_name}
|
||||||
|
<dt>{t('events.action')}</dt>
|
||||||
|
<dd>{displayEvent.action_name}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.provider_name}
|
||||||
|
<dt>{t('events.provider')}</dt>
|
||||||
|
<dd>{displayEvent.provider_name}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.assets_count > 0}
|
||||||
|
<dt>{t('events.assetsCount')}</dt>
|
||||||
|
<dd class="font-mono">{displayEvent.assets_count}</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Action buttons — deep-link + highlight the related entity card.
|
||||||
|
IDs are snapshotted into local consts so the deferred onclick
|
||||||
|
closures don't lose the narrowed type that the `{#if ...}` gate
|
||||||
|
proves at template-render time. -->
|
||||||
|
<div class="actions">
|
||||||
|
{#if displayEvent.provider_id}
|
||||||
|
{@const providerId = displayEvent.provider_id}
|
||||||
|
<button type="button" onclick={() => openEntity('/providers', providerId)}>
|
||||||
|
<MdiIcon name="mdiServer" size={14} />
|
||||||
|
{t('events.openProvider')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.telegram_bot_id && isCommand}
|
||||||
|
{@const botId = displayEvent.telegram_bot_id}
|
||||||
|
<button type="button" onclick={() => openEntity('/bots', botId)}>
|
||||||
|
<MdiIcon name="mdiRobotHappy" size={14} />
|
||||||
|
{t('events.openBot')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.command_tracker_id && isCommand}
|
||||||
|
{@const cmdTrackerId = displayEvent.command_tracker_id}
|
||||||
|
<button type="button" onclick={() => openEntity('/command-trackers', cmdTrackerId)}>
|
||||||
|
<MdiIcon name="mdiChat" size={14} />
|
||||||
|
{t('events.openCommandTracker')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if displayEvent.action_id && isAction}
|
||||||
|
{@const actionId = displayEvent.action_id}
|
||||||
|
<button type="button" onclick={() => openEntity('/actions', actionId)}>
|
||||||
|
<MdiIcon name="mdiPlayCircle" size={14} />
|
||||||
|
{t('events.openAction')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !isCommand && !isAction && displayEvent.tracker_id}
|
||||||
|
{@const trackerId = displayEvent.tracker_id}
|
||||||
|
<button type="button" onclick={() => openEntity('/notification-trackers', trackerId)}>
|
||||||
|
<MdiIcon name="mdiRadar" size={14} />
|
||||||
|
{t('events.openTracker')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
|
||||||
|
{#if detailsJson && detailsJson !== '{}'}
|
||||||
|
<details class="raw-details" open={isCommand}>
|
||||||
|
<summary>{t('events.rawDetails')}</summary>
|
||||||
|
<pre>{detailsJson}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.event-detail {
|
||||||
|
display: flex; flex-direction: column; gap: 1.1rem;
|
||||||
|
}
|
||||||
|
.hero-row {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.hero-subject {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.hero-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.event-type {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.dot { opacity: 0.5; }
|
||||||
|
|
||||||
|
.provenance {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.45rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.provenance dt {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.provenance dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
.actions button:hover {
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-details summary {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.raw-details summary:hover { color: var(--color-foreground); }
|
||||||
|
.raw-details pre {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Dispatch lifecycle banner — appears only when the event took the
|
||||||
|
* quiet-hours defer path. The three colour variants mirror the dashboard
|
||||||
|
* badge palette: primary glow for "held", success for "delivered late",
|
||||||
|
* muted/dim for "dropped" / "failed" / "suppressed".
|
||||||
|
*/
|
||||||
|
.lifecycle {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.7rem;
|
||||||
|
padding: 0.75rem 0.95rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.lifecycle-body {
|
||||||
|
display: flex; flex-direction: column; gap: 0.2rem;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.lifecycle-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.lifecycle-detail {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.lifecycle-detail b {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.lifecycle-rel {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.lifecycle-hint {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.lifecycle-reason {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.lifecycle--deferred {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
.lifecycle--deferred :global(svg) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.lifecycle--late {
|
||||||
|
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
|
||||||
|
}
|
||||||
|
.lifecycle--late :global(svg) {
|
||||||
|
color: var(--color-success, #16a34a);
|
||||||
|
}
|
||||||
|
.lifecycle--dropped {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.lifecycle--dropped :global(svg) {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
columns = 2,
|
columns = 2,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
items: GridItem[];
|
items: GridItem[];
|
||||||
value: string | number | null;
|
value: string | number | null;
|
||||||
@@ -24,6 +25,13 @@
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional one-way change callback. Fired in addition to updating
|
||||||
|
* `value` so callers that own state externally (e.g. a global store)
|
||||||
|
* can avoid the read-modify-write feedback loop that `bind:value` plus
|
||||||
|
* a sync `$effect` produces.
|
||||||
|
*/
|
||||||
|
onChange?: (value: string | number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -63,6 +71,7 @@
|
|||||||
value = item.value;
|
value = item.value;
|
||||||
open = false;
|
open = false;
|
||||||
search = '';
|
search = '';
|
||||||
|
onChange?.(item.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
|||||||
@@ -0,0 +1,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 visible = $state(false);
|
||||||
|
let mounted = $state(false);
|
||||||
let panelEl = $state<HTMLDivElement | undefined>();
|
let panelEl = $state<HTMLDivElement | undefined>();
|
||||||
let previouslyFocused: HTMLElement | null = null;
|
let previouslyFocused: HTMLElement | null = null;
|
||||||
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const TRANSITION_MS = 250;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
}
|
||||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
mounted = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
visible = true;
|
visible = true;
|
||||||
// Focus first focusable element inside the modal
|
// Focus first focusable element inside the modal
|
||||||
@@ -29,13 +37,18 @@
|
|||||||
focusable?.focus();
|
focusable?.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else if (mounted) {
|
||||||
visible = false;
|
visible = false;
|
||||||
// Restore focus to the previously focused element
|
// Restore focus to the previously focused element
|
||||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||||
previouslyFocused.focus();
|
previouslyFocused.focus();
|
||||||
previouslyFocused = null;
|
previouslyFocused = null;
|
||||||
}
|
}
|
||||||
|
if (closeTimer) clearTimeout(closeTimer);
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
mounted = false;
|
||||||
|
closeTimer = null;
|
||||||
|
}, TRANSITION_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||||
|
|
||||||
{#if open}
|
{#if mounted}
|
||||||
<div use:portal class="modal-portal-root">
|
<div use:portal class="modal-portal-root">
|
||||||
<div
|
<div
|
||||||
class="modal-backdrop"
|
class="modal-backdrop"
|
||||||
|
|||||||
@@ -32,12 +32,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if snacks.length > 0}
|
{#if snacks.length > 0}
|
||||||
<div use:portal class="snackbar-container">
|
<div use:portal class="snackbar-container" role="region" aria-label={t('snackbar.region')}>
|
||||||
{#each snacks as snack (snack.id)}
|
{#each snacks as snack (snack.id)}
|
||||||
<div
|
<div
|
||||||
in:fly={{ y: 40, duration: 300 }}
|
in:fly={{ y: 40, duration: 300 }}
|
||||||
out:fade={{ duration: 200 }}
|
out:fade={{ duration: 200 }}
|
||||||
class="snack-item"
|
class="snack-item"
|
||||||
|
role={snack.type === 'error' ? 'alert' : 'status'}
|
||||||
|
aria-live={snack.type === 'error' ? 'assertive' : 'polite'}
|
||||||
|
aria-atomic="true"
|
||||||
style="--snack-accent: {accentMap[snack.type]};"
|
style="--snack-accent: {accentMap[snack.type]};"
|
||||||
>
|
>
|
||||||
<span class="snack-icon" style="color: {accentMap[snack.type]};">
|
<span class="snack-icon" style="color: {accentMap[snack.type]};">
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Free-text chip input. Bind a string[] of values; commit a new chip on
|
||||||
|
* Enter, comma, or blur. Backspace on empty input deletes the last chip
|
||||||
|
* for parity with native chip-input UX.
|
||||||
|
*
|
||||||
|
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
|
||||||
|
* free-text filter keys like Home Assistant's entity_glob and
|
||||||
|
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
|
||||||
|
* picker dropdown sourced from an enumerable list.
|
||||||
|
*/
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
values: string[];
|
||||||
|
onchange: (values: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
icon?: string;
|
||||||
|
/** Strip / reject anything matching this regex on each entry. */
|
||||||
|
sanitize?: (raw: string) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
|
||||||
|
|
||||||
|
let draft = $state('');
|
||||||
|
|
||||||
|
function addRaw(raw: string): void {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
|
||||||
|
if (!cleaned) return;
|
||||||
|
if (values.includes(cleaned)) return;
|
||||||
|
onchange([...values, cleaned]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDraft(): void {
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
// Allow comma-separated paste — split on commas and add each.
|
||||||
|
for (const piece of draft.split(',')) {
|
||||||
|
addRaw(piece);
|
||||||
|
}
|
||||||
|
draft = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number): void {
|
||||||
|
onchange(values.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitDraft();
|
||||||
|
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
removeAt(values.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tag-input">
|
||||||
|
{#each values as value, i (`${i}-${value}`)}
|
||||||
|
<span class="tag-chip">
|
||||||
|
{#if icon}<MdiIcon name={icon} size={12} />{/if}
|
||||||
|
<span class="tag-text">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove"
|
||||||
|
class="tag-remove"
|
||||||
|
onclick={() => removeAt(i)}
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={draft}
|
||||||
|
onkeydown={onKey}
|
||||||
|
onblur={commitDraft}
|
||||||
|
placeholder={values.length === 0 ? placeholder : ''}
|
||||||
|
class="tag-draft"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-input {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-remove:hover {
|
||||||
|
background: var(--color-border);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-draft {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-draft::placeholder {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
|
|||||||
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
|
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Log level ---
|
||||||
|
|
||||||
|
export const logLevelItems = (): GridItem[] => [
|
||||||
|
{ value: 'DEBUG', icon: 'mdiBugOutline', label: 'DEBUG', desc: t('gridDesc.logLevelDebug') },
|
||||||
|
{ value: 'INFO', icon: 'mdiInformationOutline', label: 'INFO', desc: t('gridDesc.logLevelInfo') },
|
||||||
|
{ value: 'WARNING', icon: 'mdiAlertOutline', label: 'WARNING', desc: t('gridDesc.logLevelWarning') },
|
||||||
|
{ value: 'ERROR', icon: 'mdiAlertOctagonOutline', label: 'ERROR', desc: t('gridDesc.logLevelError') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Log format ---
|
||||||
|
|
||||||
|
export const logFormatItems = (): GridItem[] => [
|
||||||
|
{ value: 'text', icon: 'mdiFormatText', label: 'text', desc: t('gridDesc.logFormatText') },
|
||||||
|
{ value: 'json', icon: 'mdiCodeJson', label: 'json', desc: t('gridDesc.logFormatJson') },
|
||||||
|
];
|
||||||
|
|
||||||
// --- Response mode ---
|
// --- Response mode ---
|
||||||
|
|
||||||
export const responseModeItems = (tFn: typeof t): GridItem[] => [
|
export const responseModeItems = (tFn: typeof t): GridItem[] => [
|
||||||
@@ -92,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
|||||||
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||||
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||||
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||||
|
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
|
||||||
|
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
|
||||||
|
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Sort filter (dashboard) ---
|
// --- Sort filter (dashboard) ---
|
||||||
@@ -101,6 +120,29 @@ export const sortFilterItems = (): GridItem[] => [
|
|||||||
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Provider stats scope (dashboard "On watch" deck) ---
|
||||||
|
//
|
||||||
|
// Toggles whether the provider deck stats reflect only the events visible
|
||||||
|
// on the current page or aggregate across all events matching the filters.
|
||||||
|
|
||||||
|
export const providerStatsModeItems = (): GridItem[] => [
|
||||||
|
{ value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') },
|
||||||
|
{ value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Auto-refresh interval (dashboard events list) ---
|
||||||
|
//
|
||||||
|
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||||
|
// in routes/+page.svelte if you add or remove cadences.
|
||||||
|
|
||||||
|
export const refreshIntervalItems = (): GridItem[] => [
|
||||||
|
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
|
||||||
|
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
|
||||||
|
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
|
||||||
|
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
|
||||||
|
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
|
||||||
|
];
|
||||||
|
|
||||||
// --- Chat action (Telegram targets) ---
|
// --- Chat action (Telegram targets) ---
|
||||||
|
|
||||||
export const chatActionItems = (): GridItem[] => [
|
export const chatActionItems = (): GridItem[] => [
|
||||||
@@ -143,6 +185,19 @@ export const providerTypeFilterItems = (): GridItem[] => [
|
|||||||
...allDescriptors().map(descriptorToGridItem),
|
...allDescriptors().map(descriptorToGridItem),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Provider types the user is allowed to create from the "new provider" wizard.
|
||||||
|
*
|
||||||
|
* Excludes ``bridge_self`` because it's auto-created exactly once per user
|
||||||
|
* (see ``packages/server/.../seeds.py``). Letting users pick it from the
|
||||||
|
* wizard would either duplicate the row or surface a confusing 409.
|
||||||
|
*/
|
||||||
|
const _USER_CREATABLE_PROVIDER_TYPES = (): string[] =>
|
||||||
|
allDescriptors()
|
||||||
|
.filter((d) => d.type !== 'bridge_self')
|
||||||
|
.map((d) => d.type);
|
||||||
|
|
||||||
/** Provider type selector (no "All" option). */
|
/** Provider type selector (no "All" option). */
|
||||||
export const providerTypeItems = (): GridItem[] =>
|
export const providerTypeItems = (): GridItem[] =>
|
||||||
allDescriptors().map(descriptorToGridItem);
|
allDescriptors()
|
||||||
|
.filter((d) => _USER_CREATABLE_PROVIDER_TYPES().includes(d.type))
|
||||||
|
.map(descriptorToGridItem);
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
"name": "Notify Bridge",
|
"name": "Notify Bridge",
|
||||||
"tagline": "Service notifications"
|
"tagline": "Service notifications"
|
||||||
},
|
},
|
||||||
|
"crumbs": {
|
||||||
|
"routingNotification": "Routing · Notification",
|
||||||
|
"routingCommands": "Routing · Commands",
|
||||||
|
"routingTargets": "Routing · Targets",
|
||||||
|
"routingAutomation": "Routing · Automation",
|
||||||
|
"operatorsBots": "Operators · Bots",
|
||||||
|
"systemAccess": "System · Access",
|
||||||
|
"systemConfiguration": "System · Configuration",
|
||||||
|
"systemMaintenance": "System · Maintenance",
|
||||||
|
"serviceConnections": "Service · Connections"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"sectionOverview": "Overview",
|
"sectionOverview": "Overview",
|
||||||
"sectionRouting": "Routing",
|
"sectionRouting": "Routing",
|
||||||
@@ -87,6 +98,15 @@
|
|||||||
"actionSuccess": "action run",
|
"actionSuccess": "action run",
|
||||||
"actionPartial": "action partial",
|
"actionPartial": "action partial",
|
||||||
"actionFailed": "action failed",
|
"actionFailed": "action failed",
|
||||||
|
"commandHandled": "command handled",
|
||||||
|
"commandRateLimited": "rate limited",
|
||||||
|
"commandFailed": "command failed",
|
||||||
|
"autoRefreshTitle": "Auto-refresh interval for the events list",
|
||||||
|
"refreshOff": "Off",
|
||||||
|
"refresh10s": "10s",
|
||||||
|
"refresh30s": "30s",
|
||||||
|
"refresh60s": "1m",
|
||||||
|
"refresh5m": "5m",
|
||||||
"searchEvents": "Search events...",
|
"searchEvents": "Search events...",
|
||||||
"allEvents": "All Events",
|
"allEvents": "All Events",
|
||||||
"filterAssetsAdded": "Assets Added",
|
"filterAssetsAdded": "Assets Added",
|
||||||
@@ -97,10 +117,22 @@
|
|||||||
"filterActionSuccess": "Action Success",
|
"filterActionSuccess": "Action Success",
|
||||||
"filterActionPartial": "Action Partial",
|
"filterActionPartial": "Action Partial",
|
||||||
"filterActionFailed": "Action Failed",
|
"filterActionFailed": "Action Failed",
|
||||||
|
"filterCommandHandled": "Command Handled",
|
||||||
|
"filterCommandRateLimited": "Rate Limited",
|
||||||
|
"filterCommandFailed": "Command Failed",
|
||||||
"allProviders": "All Providers",
|
"allProviders": "All Providers",
|
||||||
"newestFirst": "Newest first",
|
"newestFirst": "Newest first",
|
||||||
"oldestFirst": "Oldest first",
|
"oldestFirst": "Oldest first",
|
||||||
"loadingEvents": "Loading events...",
|
"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",
|
"asset": "asset",
|
||||||
"assets": "assets",
|
"assets": "assets",
|
||||||
"eventActivity": "Event Activity",
|
"eventActivity": "Event Activity",
|
||||||
@@ -125,6 +157,9 @@
|
|||||||
"eventsLabel": "events",
|
"eventsLabel": "events",
|
||||||
"onWatchTitle": "On",
|
"onWatchTitle": "On",
|
||||||
"onWatchEmphasis": "watch",
|
"onWatchEmphasis": "watch",
|
||||||
|
"statsModeTitle": "Provider deck stats scope",
|
||||||
|
"statsModePage": "Page",
|
||||||
|
"statsModeAll": "All",
|
||||||
"noProviders": "No providers yet.",
|
"noProviders": "No providers yet.",
|
||||||
"addProvider": "Add provider",
|
"addProvider": "Add provider",
|
||||||
"addProviderHint": "Connect a service to start tracking",
|
"addProviderHint": "Connect a service to start tracking",
|
||||||
@@ -141,6 +176,37 @@
|
|||||||
"newTracker": "New tracker",
|
"newTracker": "New tracker",
|
||||||
"eventsTotal": "Events"
|
"eventsTotal": "Events"
|
||||||
},
|
},
|
||||||
|
"events": {
|
||||||
|
"detailTitle": "Event details",
|
||||||
|
"bot": "Bot",
|
||||||
|
"chat": "Chat",
|
||||||
|
"issuer": "Issued by",
|
||||||
|
"commandTracker": "Command tracker",
|
||||||
|
"tracker": "Tracker",
|
||||||
|
"action": "Action",
|
||||||
|
"provider": "Provider",
|
||||||
|
"assetsCount": "Assets",
|
||||||
|
"openProvider": "Open provider",
|
||||||
|
"openBot": "Open bot",
|
||||||
|
"openCommandTracker": "Open command tracker",
|
||||||
|
"openAction": "Open action",
|
||||||
|
"openTracker": "Open tracker",
|
||||||
|
"rawDetails": "Raw details",
|
||||||
|
"lifecycle": {
|
||||||
|
"heldTitle": "Held by quiet hours",
|
||||||
|
"heldUntil": "Will dispatch at",
|
||||||
|
"heldFor": "Held for",
|
||||||
|
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
|
||||||
|
"inPrefix": "in",
|
||||||
|
"deliveredLateTitle": "Delivered after quiet hours",
|
||||||
|
"originalEvent": "Original event",
|
||||||
|
"droppedTitle": "Dropped after defer",
|
||||||
|
"failedTitle": "Failed after defer",
|
||||||
|
"reason": "Reason",
|
||||||
|
"suppressedTitle": "Suppressed by quiet hours",
|
||||||
|
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
|
||||||
|
}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"title": "Service",
|
"title": "Service",
|
||||||
"titleEmphasis": "providers",
|
"titleEmphasis": "providers",
|
||||||
@@ -169,6 +235,20 @@
|
|||||||
"typeNut": "NUT (UPS)",
|
"typeNut": "NUT (UPS)",
|
||||||
"typeGooglePhotos": "Google Photos",
|
"typeGooglePhotos": "Google Photos",
|
||||||
"typeWebhook": "Generic Webhook",
|
"typeWebhook": "Generic Webhook",
|
||||||
|
"typeHomeAssistant": "Home Assistant",
|
||||||
|
"typeBridgeSelf": "Bridge Self-Monitoring",
|
||||||
|
"bridgeSelfPollThreshold": "Tracker poll failure threshold",
|
||||||
|
"bridgeSelfPollThresholdHint": "Notify after this many consecutive poll failures for any tracker.",
|
||||||
|
"bridgeSelfDeferredThreshold": "Deferred backlog threshold",
|
||||||
|
"bridgeSelfDeferredThresholdHint": "Notify when pending deferred-dispatch rows exceed this count.",
|
||||||
|
"bridgeSelfTargetThreshold": "Target send failure threshold",
|
||||||
|
"bridgeSelfTargetThresholdHint": "Notify after this many consecutive 5xx/network failures for any target.",
|
||||||
|
"haAccessToken": "Long-Lived Access Token",
|
||||||
|
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
|
||||||
|
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
|
||||||
|
"haAccessTokenRequired": "Home Assistant access token is required.",
|
||||||
|
"haVerifyTls": "Verify TLS certificate",
|
||||||
|
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
|
||||||
"loadError": "Failed to load providers.",
|
"loadError": "Failed to load providers.",
|
||||||
"externalDomain": "External Domain",
|
"externalDomain": "External Domain",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
@@ -192,7 +272,8 @@
|
|||||||
"apiToken": "API Token",
|
"apiToken": "API Token",
|
||||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||||
"webhookUrl": "Webhook URL",
|
"webhookUrl": "Webhook URL",
|
||||||
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
|
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings. The full URL is shown when an external base URL is configured in Settings; otherwise it is relative to your bridge host.",
|
||||||
|
"webhookUrlCopyTitle": "Click to copy",
|
||||||
"nutHost": "NUT Server Host",
|
"nutHost": "NUT Server Host",
|
||||||
"nutHostPlaceholder": "192.168.1.100 or ups.local",
|
"nutHostPlaceholder": "192.168.1.100 or ups.local",
|
||||||
"nutPort": "NUT Server Port",
|
"nutPort": "NUT Server Port",
|
||||||
@@ -253,6 +334,13 @@
|
|||||||
"selectBoards": "Select boards...",
|
"selectBoards": "Select boards...",
|
||||||
"upsDevices": "UPS Devices",
|
"upsDevices": "UPS Devices",
|
||||||
"selectUpsDevices": "Select UPS devices...",
|
"selectUpsDevices": "Select UPS devices...",
|
||||||
|
"entities": "Entities",
|
||||||
|
"selectEntities": "Select entities...",
|
||||||
|
"entities_count": "entity(ies)",
|
||||||
|
"haEntityGlob": "Entity glob filter",
|
||||||
|
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||||
|
"haDomainAllowlist": "Domain allowlist",
|
||||||
|
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||||
"eventTypes": "Event Types",
|
"eventTypes": "Event Types",
|
||||||
"notificationTargets": "Notification Targets",
|
"notificationTargets": "Notification Targets",
|
||||||
"scanInterval": "Scan Interval (seconds)",
|
"scanInterval": "Scan Interval (seconds)",
|
||||||
@@ -312,6 +400,7 @@
|
|||||||
"checkingLinks": "Checking links...",
|
"checkingLinks": "Checking links...",
|
||||||
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
|
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
|
||||||
"openTrackingConfig": "Open Tracking Config",
|
"openTrackingConfig": "Open Tracking Config",
|
||||||
|
"openTemplateConfig": "Open Template Config",
|
||||||
"linkReplace": "Replace",
|
"linkReplace": "Replace",
|
||||||
"linkReplacing": "Replacing...",
|
"linkReplacing": "Replacing...",
|
||||||
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
|
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
|
||||||
@@ -419,13 +508,20 @@
|
|||||||
"receiverUpdated": "Receiver updated",
|
"receiverUpdated": "Receiver updated",
|
||||||
"confirmDeleteReceiver": "Delete this receiver?",
|
"confirmDeleteReceiver": "Delete this receiver?",
|
||||||
"receiverEnabled": "Receiver enabled",
|
"receiverEnabled": "Receiver enabled",
|
||||||
"receiverDisabled": "Receiver disabled"
|
"receiverDisabled": "Receiver disabled",
|
||||||
|
"groupNoBot": "No bot linked",
|
||||||
|
"groupDirect": "Direct delivery",
|
||||||
|
"groupBotMissing": "Unknown bot",
|
||||||
|
"target": "target",
|
||||||
|
"targetsLower": "targets",
|
||||||
|
"openBot": "Open bot"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"titleEmphasis": "& access",
|
"titleEmphasis": "& access",
|
||||||
"countLabel": "users",
|
"countLabel": "users",
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"description": "Manage user accounts (admin only)",
|
"description": "Manage user accounts (admin only)",
|
||||||
|
"you": "you",
|
||||||
"addUser": "Add User",
|
"addUser": "Add User",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@@ -569,6 +665,14 @@
|
|||||||
"upsOverload": "UPS overloaded",
|
"upsOverload": "UPS overloaded",
|
||||||
"scheduledMessage": "Scheduled message",
|
"scheduledMessage": "Scheduled message",
|
||||||
"webhookReceived": "Webhook received",
|
"webhookReceived": "Webhook received",
|
||||||
|
"haStateChanged": "Entity state changed",
|
||||||
|
"haAutomationTriggered": "Automation triggered",
|
||||||
|
"haServiceCalled": "Service called",
|
||||||
|
"haEventFired": "Other HA event (catch-all)",
|
||||||
|
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
|
||||||
|
"bridgeSelfPollFailures": "Tracker poll failures",
|
||||||
|
"bridgeSelfDeferredBacklog": "Deferred backlog crossed threshold",
|
||||||
|
"bridgeSelfTargetFailures": "Target send failures",
|
||||||
"trackImages": "Track images",
|
"trackImages": "Track images",
|
||||||
"trackVideos": "Track videos",
|
"trackVideos": "Track videos",
|
||||||
"favoritesOnly": "Favorites only",
|
"favoritesOnly": "Favorites only",
|
||||||
@@ -788,7 +892,92 @@
|
|||||||
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
||||||
"logLevels": "Per-Module Overrides",
|
"logLevels": "Per-Module Overrides",
|
||||||
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved",
|
||||||
|
"identity": "Identity",
|
||||||
|
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
|
||||||
|
"telegramHeadline": "Webhook authentication and media cache tuning",
|
||||||
|
"loggingHeadline": "Verbosity, output format, and per-module overrides",
|
||||||
|
"heroNoUrl": "External URL not set",
|
||||||
|
"heroNoLocales": "no locales",
|
||||||
|
"copy": "Copy",
|
||||||
|
"urlCopied": "URL copied",
|
||||||
|
"openExternal": "Open",
|
||||||
|
"show": "Show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"secretSet": "Verified",
|
||||||
|
"secretUnset": "Not configured",
|
||||||
|
"cacheConfig": "Cache",
|
||||||
|
"cacheTtlShort": "TTL",
|
||||||
|
"cacheMaxShort": "Max entries",
|
||||||
|
"cacheMaxFootnote": "per bucket (LRU)",
|
||||||
|
"hoursShort": "hrs",
|
||||||
|
"entriesShort": "max",
|
||||||
|
"ttlNoExpiry": "no expiry",
|
||||||
|
"cacheCapacity": "Cache capacity",
|
||||||
|
"cacheCapacityCap": "of {n} cap",
|
||||||
|
"logModulePlaceholder": "module.path",
|
||||||
|
"addOverride": "Add override",
|
||||||
|
"removeOverride": "Remove",
|
||||||
|
"editAsText": "Edit as text",
|
||||||
|
"editAsChips": "Edit as chips",
|
||||||
|
"logPreviewLabel": "ACTIVE",
|
||||||
|
"unsavedChanges": "Unsaved changes",
|
||||||
|
"unsaved": "UNSAVED",
|
||||||
|
"changedOne": "1 setting changed",
|
||||||
|
"changedMany": "{n} settings changed",
|
||||||
|
"discard": "Discard",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"release": {
|
||||||
|
"eyebrow": "Releases",
|
||||||
|
"headline": "Stay current with upstream",
|
||||||
|
"provider": "Provider",
|
||||||
|
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"repository": "Repository",
|
||||||
|
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
|
||||||
|
"options": "Options",
|
||||||
|
"includePrereleases": "Include pre-releases",
|
||||||
|
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
|
||||||
|
"interval": "Check interval",
|
||||||
|
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
|
||||||
|
"intervalRange": "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": {
|
"hints": {
|
||||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||||
@@ -888,6 +1077,7 @@
|
|||||||
"titleEmphasis": "configs",
|
"titleEmphasis": "configs",
|
||||||
"countLabel": "configs",
|
"countLabel": "configs",
|
||||||
"title": "Command Configs",
|
"title": "Command Configs",
|
||||||
|
"noCommandsForProvider": "No commands available for this provider type.",
|
||||||
"description": "Define command settings for Telegram bot interactions",
|
"description": "Define command settings for Telegram bot interactions",
|
||||||
"newConfig": "New Config",
|
"newConfig": "New Config",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -937,9 +1127,22 @@
|
|||||||
"scopeInherit": "Inherit: derive from notification routing",
|
"scopeInherit": "Inherit: derive from notification routing",
|
||||||
"noCollections": "No albums available."
|
"noCollections": "No albums available."
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"bridgeSelf": {
|
||||||
|
"status": "Bridge status",
|
||||||
|
"statusDesc": "Show current bridge health counters",
|
||||||
|
"thresholds": "Bridge thresholds",
|
||||||
|
"thresholdsDesc": "Show configured alert thresholds",
|
||||||
|
"reset": "Reset counter",
|
||||||
|
"resetDesc": "Manually reset a failure counter",
|
||||||
|
"health": "Bridge health",
|
||||||
|
"healthDesc": "Terse one-line health summary"
|
||||||
|
}
|
||||||
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
"showDetails": "Show details",
|
"showDetails": "Show details",
|
||||||
"hideDetails": "Hide details"
|
"hideDetails": "Hide details",
|
||||||
|
"region": "Notifications"
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"searchPlaceholder": "Search cities or IANA codes…",
|
"searchPlaceholder": "Search cities or IANA codes…",
|
||||||
@@ -948,6 +1151,8 @@
|
|||||||
"noMatches": "No timezones match"
|
"noMatches": "No timezones match"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
|
"label": "language",
|
||||||
|
"labelPlural": "languages",
|
||||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||||
"add": "Add language",
|
"add": "Add language",
|
||||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||||
@@ -1017,6 +1222,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"auto": "Auto",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -1024,6 +1230,8 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"hide": "Hide",
|
||||||
|
"show": "Show",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cannotDelete": "Cannot delete",
|
"cannotDelete": "Cannot delete",
|
||||||
"blockedByIntro": "Referenced by:",
|
"blockedByIntro": "Referenced by:",
|
||||||
@@ -1131,6 +1339,12 @@
|
|||||||
"memorySourceNative": "Use Immich native memories API",
|
"memorySourceNative": "Use Immich native memories API",
|
||||||
"localeEn": "English interface",
|
"localeEn": "English interface",
|
||||||
"localeRu": "Russian interface",
|
"localeRu": "Russian interface",
|
||||||
|
"logLevelDebug": "Verbose — show every step",
|
||||||
|
"logLevelInfo": "Default — high-level events",
|
||||||
|
"logLevelWarning": "Warnings and errors only",
|
||||||
|
"logLevelError": "Errors only — quietest",
|
||||||
|
"logFormatText": "Human-readable plain text",
|
||||||
|
"logFormatJson": "One JSON object per line",
|
||||||
"modeMedia": "Send actual photo/video files",
|
"modeMedia": "Send actual photo/video files",
|
||||||
"modeText": "Send file names and links only",
|
"modeText": "Send file names and links only",
|
||||||
"allEvents": "Show all event types",
|
"allEvents": "Show all event types",
|
||||||
@@ -1142,6 +1356,16 @@
|
|||||||
"actionSuccess": "Scheduled action completed",
|
"actionSuccess": "Scheduled action completed",
|
||||||
"actionPartial": "Scheduled action partially succeeded",
|
"actionPartial": "Scheduled action partially succeeded",
|
||||||
"actionFailed": "Scheduled action failed",
|
"actionFailed": "Scheduled action failed",
|
||||||
|
"commandHandled": "Bot command served",
|
||||||
|
"commandRateLimited": "Bot command throttled",
|
||||||
|
"commandFailed": "Bot command crashed",
|
||||||
|
"refreshOff": "Auto-refresh disabled",
|
||||||
|
"refresh10s": "Refresh every 10 seconds",
|
||||||
|
"refresh30s": "Refresh every 30 seconds",
|
||||||
|
"refresh60s": "Refresh every minute",
|
||||||
|
"refresh5m": "Refresh every 5 minutes",
|
||||||
|
"statsModePage": "Count only events on the current page",
|
||||||
|
"statsModeAll": "Count all events matching the current filters",
|
||||||
"newestFirst": "Most recent events on top",
|
"newestFirst": "Most recent events on top",
|
||||||
"oldestFirst": "Oldest events on top",
|
"oldestFirst": "Oldest events on top",
|
||||||
"chatActionNone": "No indicator shown",
|
"chatActionNone": "No indicator shown",
|
||||||
@@ -1164,7 +1388,9 @@
|
|||||||
"providerScheduler": "Time-based scheduled messages",
|
"providerScheduler": "Time-based scheduled messages",
|
||||||
"providerNut": "Network UPS monitoring",
|
"providerNut": "Network UPS monitoring",
|
||||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
||||||
"providerWebhook": "Receive events via HTTP POST"
|
"providerWebhook": "Receive events via HTTP POST",
|
||||||
|
"providerHomeAssistant": "Home Assistant event bus over WebSocket",
|
||||||
|
"providerBridgeSelf": "Internal health alerts when polling, dispatch, or sends fail"
|
||||||
},
|
},
|
||||||
"webhookLogs": {
|
"webhookLogs": {
|
||||||
"title": "Recent Payloads",
|
"title": "Recent Payloads",
|
||||||
@@ -1324,6 +1550,30 @@
|
|||||||
"applyLater": "Apply later",
|
"applyLater": "Apply later",
|
||||||
"restartNow": "Restart now",
|
"restartNow": "Restart now",
|
||||||
"restartingTitle": "Restarting backend…",
|
"restartingTitle": "Restarting backend…",
|
||||||
"restartingDescription": "The page will reload once the server is back online."
|
"restartingDescription": "The page will reload once the server is back online.",
|
||||||
|
"countLabel": "backups",
|
||||||
|
"scheduleOn": "Auto · every {h}h",
|
||||||
|
"scheduleOff": "Auto backup off",
|
||||||
|
"lastBackup": "Last {ago}",
|
||||||
|
"never": "no backups yet",
|
||||||
|
"totalSize": "{size} total",
|
||||||
|
"dropZone": "Drop a JSON backup here, or click to choose",
|
||||||
|
"dropZoneActive": "Release to load",
|
||||||
|
"changeFile": "Change file",
|
||||||
|
"catGroupIdentity": "Identity & Routing",
|
||||||
|
"catGroupNotif": "Notifications",
|
||||||
|
"catGroupCmd": "Commands",
|
||||||
|
"catGroupSystem": "System",
|
||||||
|
"stepCategories": "What to include",
|
||||||
|
"stepSecrets": "Secrets handling",
|
||||||
|
"stepDownload": "Download",
|
||||||
|
"stepFile": "Choose a file",
|
||||||
|
"stepValidate": "Validate contents",
|
||||||
|
"stepConflict": "On conflict",
|
||||||
|
"stepApply": "Apply",
|
||||||
|
"tagScheduled": "scheduled",
|
||||||
|
"tagManual": "manual",
|
||||||
|
"tagSecrets": "with secrets",
|
||||||
|
"validateFirst": "Validate the file first to enable import"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,17 @@
|
|||||||
"name": "Notify Bridge",
|
"name": "Notify Bridge",
|
||||||
"tagline": "Уведомления о сервисах"
|
"tagline": "Уведомления о сервисах"
|
||||||
},
|
},
|
||||||
|
"crumbs": {
|
||||||
|
"routingNotification": "Маршрутизация · Уведомления",
|
||||||
|
"routingCommands": "Маршрутизация · Команды",
|
||||||
|
"routingTargets": "Маршрутизация · Цели",
|
||||||
|
"routingAutomation": "Маршрутизация · Автоматизация",
|
||||||
|
"operatorsBots": "Операторы · Боты",
|
||||||
|
"systemAccess": "Система · Доступ",
|
||||||
|
"systemConfiguration": "Система · Настройки",
|
||||||
|
"systemMaintenance": "Система · Обслуживание",
|
||||||
|
"serviceConnections": "Сервис · Подключения"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"sectionOverview": "Обзор",
|
"sectionOverview": "Обзор",
|
||||||
"sectionRouting": "Маршрутизация",
|
"sectionRouting": "Маршрутизация",
|
||||||
@@ -87,6 +98,15 @@
|
|||||||
"actionSuccess": "действие выполнено",
|
"actionSuccess": "действие выполнено",
|
||||||
"actionPartial": "действие частично",
|
"actionPartial": "действие частично",
|
||||||
"actionFailed": "действие провалено",
|
"actionFailed": "действие провалено",
|
||||||
|
"commandHandled": "команда обработана",
|
||||||
|
"commandRateLimited": "ограничение частоты",
|
||||||
|
"commandFailed": "команда упала",
|
||||||
|
"autoRefreshTitle": "Интервал авто-обновления списка событий",
|
||||||
|
"refreshOff": "Выкл",
|
||||||
|
"refresh10s": "10с",
|
||||||
|
"refresh30s": "30с",
|
||||||
|
"refresh60s": "1м",
|
||||||
|
"refresh5m": "5м",
|
||||||
"searchEvents": "Поиск событий...",
|
"searchEvents": "Поиск событий...",
|
||||||
"allEvents": "Все события",
|
"allEvents": "Все события",
|
||||||
"filterAssetsAdded": "Добавление файлов",
|
"filterAssetsAdded": "Добавление файлов",
|
||||||
@@ -97,10 +117,22 @@
|
|||||||
"filterActionSuccess": "Действие выполнено",
|
"filterActionSuccess": "Действие выполнено",
|
||||||
"filterActionPartial": "Действие частично",
|
"filterActionPartial": "Действие частично",
|
||||||
"filterActionFailed": "Действие провалено",
|
"filterActionFailed": "Действие провалено",
|
||||||
|
"filterCommandHandled": "Команда обработана",
|
||||||
|
"filterCommandRateLimited": "Ограничение частоты",
|
||||||
|
"filterCommandFailed": "Команда упала",
|
||||||
"allProviders": "Все провайдеры",
|
"allProviders": "Все провайдеры",
|
||||||
"newestFirst": "Сначала новые",
|
"newestFirst": "Сначала новые",
|
||||||
"oldestFirst": "Сначала старые",
|
"oldestFirst": "Сначала старые",
|
||||||
"loadingEvents": "Загрузка событий...",
|
"loadingEvents": "Загрузка событий...",
|
||||||
|
"heldUntil": "ожидает до",
|
||||||
|
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
|
||||||
|
"deliveredLate": "доставлено позже",
|
||||||
|
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
|
||||||
|
"deferredThenDropped": "отброшено после задержки",
|
||||||
|
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
|
||||||
|
"deferredThenFailed": "ошибка после задержки",
|
||||||
|
"suppressedQuietHours": "подавлено (тихие часы)",
|
||||||
|
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
|
||||||
"asset": "файл",
|
"asset": "файл",
|
||||||
"assets": "файлов",
|
"assets": "файлов",
|
||||||
"eventActivity": "Активность событий",
|
"eventActivity": "Активность событий",
|
||||||
@@ -125,6 +157,9 @@
|
|||||||
"eventsLabel": "событий",
|
"eventsLabel": "событий",
|
||||||
"onWatchTitle": "На",
|
"onWatchTitle": "На",
|
||||||
"onWatchEmphasis": "слежении",
|
"onWatchEmphasis": "слежении",
|
||||||
|
"statsModeTitle": "Область статистики провайдеров",
|
||||||
|
"statsModePage": "Страница",
|
||||||
|
"statsModeAll": "Все",
|
||||||
"noProviders": "Пока нет провайдеров.",
|
"noProviders": "Пока нет провайдеров.",
|
||||||
"addProvider": "Добавить",
|
"addProvider": "Добавить",
|
||||||
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
"addProviderHint": "Подключите сервис, чтобы начать слежение",
|
||||||
@@ -141,6 +176,37 @@
|
|||||||
"newTracker": "Новый трекер",
|
"newTracker": "Новый трекер",
|
||||||
"eventsTotal": "Событий"
|
"eventsTotal": "Событий"
|
||||||
},
|
},
|
||||||
|
"events": {
|
||||||
|
"detailTitle": "Детали события",
|
||||||
|
"bot": "Бот",
|
||||||
|
"chat": "Чат",
|
||||||
|
"issuer": "Отправитель",
|
||||||
|
"commandTracker": "Командный трекер",
|
||||||
|
"tracker": "Трекер",
|
||||||
|
"action": "Действие",
|
||||||
|
"provider": "Провайдер",
|
||||||
|
"assetsCount": "Файлов",
|
||||||
|
"openProvider": "Открыть провайдера",
|
||||||
|
"openBot": "Открыть бота",
|
||||||
|
"openCommandTracker": "Открыть командный трекер",
|
||||||
|
"openAction": "Открыть действие",
|
||||||
|
"openTracker": "Открыть трекер",
|
||||||
|
"rawDetails": "Сырые данные",
|
||||||
|
"lifecycle": {
|
||||||
|
"heldTitle": "Задержано тихими часами",
|
||||||
|
"heldUntil": "Будет отправлено в",
|
||||||
|
"heldFor": "Задержано на",
|
||||||
|
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
|
||||||
|
"inPrefix": "через",
|
||||||
|
"deliveredLateTitle": "Доставлено после тихих часов",
|
||||||
|
"originalEvent": "Исходное событие",
|
||||||
|
"droppedTitle": "Отброшено после задержки",
|
||||||
|
"failedTitle": "Ошибка после задержки",
|
||||||
|
"reason": "Причина",
|
||||||
|
"suppressedTitle": "Подавлено тихими часами",
|
||||||
|
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
|
||||||
|
}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"title": "Сервисные",
|
"title": "Сервисные",
|
||||||
"titleEmphasis": "провайдеры",
|
"titleEmphasis": "провайдеры",
|
||||||
@@ -169,6 +235,20 @@
|
|||||||
"typeNut": "NUT (ИБП)",
|
"typeNut": "NUT (ИБП)",
|
||||||
"typeGooglePhotos": "Google Фото",
|
"typeGooglePhotos": "Google Фото",
|
||||||
"typeWebhook": "Универсальный вебхук",
|
"typeWebhook": "Универсальный вебхук",
|
||||||
|
"typeHomeAssistant": "Home Assistant",
|
||||||
|
"typeBridgeSelf": "Самомониторинг моста",
|
||||||
|
"bridgeSelfPollThreshold": "Порог сбоев опроса трекера",
|
||||||
|
"bridgeSelfPollThresholdHint": "Уведомлять после стольких подряд сбоев опроса любого трекера.",
|
||||||
|
"bridgeSelfDeferredThreshold": "Порог очереди отложенной отправки",
|
||||||
|
"bridgeSelfDeferredThresholdHint": "Уведомлять, когда количество ожидающих записей deferred_dispatch превысит это значение.",
|
||||||
|
"bridgeSelfTargetThreshold": "Порог сбоев отправки в адресат",
|
||||||
|
"bridgeSelfTargetThresholdHint": "Уведомлять после стольких подряд сбоев 5xx/сети при отправке в любой адресат.",
|
||||||
|
"haAccessToken": "Долгоживущий токен доступа",
|
||||||
|
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
|
||||||
|
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
|
||||||
|
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
|
||||||
|
"haVerifyTls": "Проверять TLS-сертификат",
|
||||||
|
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
|
||||||
"loadError": "Не удалось загрузить провайдеры.",
|
"loadError": "Не удалось загрузить провайдеры.",
|
||||||
"externalDomain": "Внешний домен",
|
"externalDomain": "Внешний домен",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
@@ -192,7 +272,8 @@
|
|||||||
"apiToken": "API токен",
|
"apiToken": "API токен",
|
||||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||||
"webhookUrl": "URL вебхука",
|
"webhookUrl": "URL вебхука",
|
||||||
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
|
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
|
||||||
|
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
|
||||||
"nutHost": "Хост NUT-сервера",
|
"nutHost": "Хост NUT-сервера",
|
||||||
"nutHostPlaceholder": "192.168.1.100 или ups.local",
|
"nutHostPlaceholder": "192.168.1.100 или ups.local",
|
||||||
"nutPort": "Порт NUT-сервера",
|
"nutPort": "Порт NUT-сервера",
|
||||||
@@ -253,6 +334,13 @@
|
|||||||
"selectBoards": "Выберите доски...",
|
"selectBoards": "Выберите доски...",
|
||||||
"upsDevices": "ИБП устройства",
|
"upsDevices": "ИБП устройства",
|
||||||
"selectUpsDevices": "Выберите ИБП...",
|
"selectUpsDevices": "Выберите ИБП...",
|
||||||
|
"entities": "Сущности",
|
||||||
|
"selectEntities": "Выберите сущности...",
|
||||||
|
"entities_count": "сущность(ей)",
|
||||||
|
"haEntityGlob": "Фильтр по entity (glob)",
|
||||||
|
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||||
|
"haDomainAllowlist": "Разрешённые домены",
|
||||||
|
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||||
"eventTypes": "Типы событий",
|
"eventTypes": "Типы событий",
|
||||||
"notificationTargets": "Получатели уведомлений",
|
"notificationTargets": "Получатели уведомлений",
|
||||||
"scanInterval": "Интервал проверки (секунды)",
|
"scanInterval": "Интервал проверки (секунды)",
|
||||||
@@ -312,6 +400,7 @@
|
|||||||
"checkingLinks": "Проверка ссылок...",
|
"checkingLinks": "Проверка ссылок...",
|
||||||
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
||||||
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
||||||
|
"openTemplateConfig": "Открыть конфигурацию шаблона",
|
||||||
"linkReplace": "Пересоздать",
|
"linkReplace": "Пересоздать",
|
||||||
"linkReplacing": "Пересоздание...",
|
"linkReplacing": "Пересоздание...",
|
||||||
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
||||||
@@ -419,13 +508,20 @@
|
|||||||
"receiverUpdated": "Получатель обновлён",
|
"receiverUpdated": "Получатель обновлён",
|
||||||
"confirmDeleteReceiver": "Удалить этого получателя?",
|
"confirmDeleteReceiver": "Удалить этого получателя?",
|
||||||
"receiverEnabled": "Получатель включён",
|
"receiverEnabled": "Получатель включён",
|
||||||
"receiverDisabled": "Получатель отключён"
|
"receiverDisabled": "Получатель отключён",
|
||||||
|
"groupNoBot": "Без привязки к боту",
|
||||||
|
"groupDirect": "Прямая доставка",
|
||||||
|
"groupBotMissing": "Неизвестный бот",
|
||||||
|
"target": "получатель",
|
||||||
|
"targetsLower": "получателей",
|
||||||
|
"openBot": "Открыть бота"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"titleEmphasis": "и доступ",
|
"titleEmphasis": "и доступ",
|
||||||
"countLabel": "пользователей",
|
"countLabel": "пользователей",
|
||||||
"title": "Пользователи",
|
"title": "Пользователи",
|
||||||
"description": "Управление аккаунтами (только админ)",
|
"description": "Управление аккаунтами (только админ)",
|
||||||
|
"you": "вы",
|
||||||
"addUser": "Добавить пользователя",
|
"addUser": "Добавить пользователя",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
@@ -569,6 +665,14 @@
|
|||||||
"upsOverload": "Перегрузка ИБП",
|
"upsOverload": "Перегрузка ИБП",
|
||||||
"scheduledMessage": "Запланированное сообщение",
|
"scheduledMessage": "Запланированное сообщение",
|
||||||
"webhookReceived": "Вебхук получен",
|
"webhookReceived": "Вебхук получен",
|
||||||
|
"haStateChanged": "Состояние сущности изменилось",
|
||||||
|
"haAutomationTriggered": "Сработала автоматизация",
|
||||||
|
"haServiceCalled": "Вызвана служба",
|
||||||
|
"haEventFired": "Прочее событие HA (catch-all)",
|
||||||
|
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||||
|
"bridgeSelfPollFailures": "Сбои опроса трекера",
|
||||||
|
"bridgeSelfDeferredBacklog": "Очередь отложенной отправки превысила порог",
|
||||||
|
"bridgeSelfTargetFailures": "Сбои отправки в адресат",
|
||||||
"trackImages": "Фото",
|
"trackImages": "Фото",
|
||||||
"trackVideos": "Видео",
|
"trackVideos": "Видео",
|
||||||
"favoritesOnly": "Только избранные",
|
"favoritesOnly": "Только избранные",
|
||||||
@@ -788,7 +892,92 @@
|
|||||||
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
||||||
"logLevels": "Переопределения по модулям",
|
"logLevels": "Переопределения по модулям",
|
||||||
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены",
|
||||||
|
"identity": "Идентификация",
|
||||||
|
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
|
||||||
|
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
|
||||||
|
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
|
||||||
|
"heroNoUrl": "Внешний URL не задан",
|
||||||
|
"heroNoLocales": "нет локалей",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"urlCopied": "URL скопирован",
|
||||||
|
"openExternal": "Открыть",
|
||||||
|
"show": "Показать",
|
||||||
|
"hide": "Скрыть",
|
||||||
|
"secretSet": "Задан",
|
||||||
|
"secretUnset": "Не настроен",
|
||||||
|
"cacheConfig": "Кэш",
|
||||||
|
"cacheTtlShort": "TTL",
|
||||||
|
"cacheMaxShort": "Макс. записей",
|
||||||
|
"cacheMaxFootnote": "на корзину (LRU)",
|
||||||
|
"hoursShort": "ч",
|
||||||
|
"entriesShort": "макс",
|
||||||
|
"ttlNoExpiry": "без срока",
|
||||||
|
"cacheCapacity": "Заполненность кэша",
|
||||||
|
"cacheCapacityCap": "из {n}",
|
||||||
|
"logModulePlaceholder": "путь.модуля",
|
||||||
|
"addOverride": "Добавить",
|
||||||
|
"removeOverride": "Удалить",
|
||||||
|
"editAsText": "Редактировать как текст",
|
||||||
|
"editAsChips": "Редактировать как чипы",
|
||||||
|
"logPreviewLabel": "АКТИВНО",
|
||||||
|
"unsavedChanges": "Несохранённые изменения",
|
||||||
|
"unsaved": "НЕ СОХРАНЕНО",
|
||||||
|
"changedOne": "Изменена 1 настройка",
|
||||||
|
"changedMany": "Изменено настроек: {n}",
|
||||||
|
"discard": "Отменить",
|
||||||
|
"saveChanges": "Сохранить",
|
||||||
|
"release": {
|
||||||
|
"eyebrow": "Релизы",
|
||||||
|
"headline": "Следите за обновлениями",
|
||||||
|
"provider": "Источник",
|
||||||
|
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
|
||||||
|
"comingSoon": "Скоро",
|
||||||
|
"disabled": "Отключено",
|
||||||
|
"repository": "Репозиторий",
|
||||||
|
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
|
||||||
|
"options": "Опции",
|
||||||
|
"includePrereleases": "Учитывать пре-релизы",
|
||||||
|
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
|
||||||
|
"interval": "Интервал проверки",
|
||||||
|
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
|
||||||
|
"intervalRange": "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": {
|
"hints": {
|
||||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||||
@@ -888,6 +1077,7 @@
|
|||||||
"titleEmphasis": "конфигурации",
|
"titleEmphasis": "конфигурации",
|
||||||
"countLabel": "конфигураций",
|
"countLabel": "конфигураций",
|
||||||
"title": "Конфигурации команд",
|
"title": "Конфигурации команд",
|
||||||
|
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||||
"newConfig": "Новая конфигурация",
|
"newConfig": "Новая конфигурация",
|
||||||
"name": "Название",
|
"name": "Название",
|
||||||
@@ -937,9 +1127,22 @@
|
|||||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||||
"noCollections": "Нет доступных альбомов."
|
"noCollections": "Нет доступных альбомов."
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"bridgeSelf": {
|
||||||
|
"status": "Состояние моста",
|
||||||
|
"statusDesc": "Показать счётчики состояния моста",
|
||||||
|
"thresholds": "Пороги моста",
|
||||||
|
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||||
|
"reset": "Сбросить счётчик",
|
||||||
|
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||||
|
"health": "Здоровье моста",
|
||||||
|
"healthDesc": "Краткая однострочная сводка состояния"
|
||||||
|
}
|
||||||
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
"showDetails": "Показать детали",
|
"showDetails": "Показать детали",
|
||||||
"hideDetails": "Скрыть детали"
|
"hideDetails": "Скрыть детали",
|
||||||
|
"region": "Уведомления"
|
||||||
},
|
},
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||||
@@ -948,6 +1151,8 @@
|
|||||||
"noMatches": "Нет совпадений"
|
"noMatches": "Нет совпадений"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
|
"label": "язык",
|
||||||
|
"labelPlural": "языков",
|
||||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||||
"add": "Добавить язык",
|
"add": "Добавить язык",
|
||||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||||
@@ -1017,6 +1222,7 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
"auto": "Авто",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
@@ -1024,6 +1230,8 @@
|
|||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
|
"hide": "Скрыть",
|
||||||
|
"show": "Показать",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"cannotDelete": "Невозможно удалить",
|
"cannotDelete": "Невозможно удалить",
|
||||||
"blockedByIntro": "На объект ссылаются:",
|
"blockedByIntro": "На объект ссылаются:",
|
||||||
@@ -1131,6 +1339,12 @@
|
|||||||
"memorySourceNative": "Использовать API воспоминаний Immich",
|
"memorySourceNative": "Использовать API воспоминаний Immich",
|
||||||
"localeEn": "Английский интерфейс",
|
"localeEn": "Английский интерфейс",
|
||||||
"localeRu": "Русский интерфейс",
|
"localeRu": "Русский интерфейс",
|
||||||
|
"logLevelDebug": "Подробный — каждый шаг",
|
||||||
|
"logLevelInfo": "По умолчанию — ключевые события",
|
||||||
|
"logLevelWarning": "Только предупреждения и ошибки",
|
||||||
|
"logLevelError": "Только ошибки — самый тихий",
|
||||||
|
"logFormatText": "Читаемый человеком текст",
|
||||||
|
"logFormatJson": "Один JSON-объект на строку",
|
||||||
"modeMedia": "Отправка файлов фото/видео",
|
"modeMedia": "Отправка файлов фото/видео",
|
||||||
"modeText": "Только имена файлов и ссылки",
|
"modeText": "Только имена файлов и ссылки",
|
||||||
"allEvents": "Показать все типы событий",
|
"allEvents": "Показать все типы событий",
|
||||||
@@ -1142,6 +1356,16 @@
|
|||||||
"actionSuccess": "Запланированное действие выполнено",
|
"actionSuccess": "Запланированное действие выполнено",
|
||||||
"actionPartial": "Запланированное действие выполнено частично",
|
"actionPartial": "Запланированное действие выполнено частично",
|
||||||
"actionFailed": "Запланированное действие провалено",
|
"actionFailed": "Запланированное действие провалено",
|
||||||
|
"commandHandled": "Команда бота обработана",
|
||||||
|
"commandRateLimited": "Команда бота ограничена по частоте",
|
||||||
|
"commandFailed": "Команда бота вызвала ошибку",
|
||||||
|
"refreshOff": "Автообновление выключено",
|
||||||
|
"refresh10s": "Обновлять каждые 10 секунд",
|
||||||
|
"refresh30s": "Обновлять каждые 30 секунд",
|
||||||
|
"refresh60s": "Обновлять каждую минуту",
|
||||||
|
"refresh5m": "Обновлять каждые 5 минут",
|
||||||
|
"statsModePage": "Учитывать только события на текущей странице",
|
||||||
|
"statsModeAll": "Учитывать все события под текущими фильтрами",
|
||||||
"newestFirst": "Сначала новые события",
|
"newestFirst": "Сначала новые события",
|
||||||
"oldestFirst": "Сначала старые события",
|
"oldestFirst": "Сначала старые события",
|
||||||
"chatActionNone": "Индикатор не показывается",
|
"chatActionNone": "Индикатор не показывается",
|
||||||
@@ -1164,7 +1388,9 @@
|
|||||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||||
"providerNut": "Мониторинг ИБП через NUT",
|
"providerNut": "Мониторинг ИБП через NUT",
|
||||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||||
"providerWebhook": "Приём событий через HTTP POST"
|
"providerWebhook": "Приём событий через HTTP POST",
|
||||||
|
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket",
|
||||||
|
"providerBridgeSelf": "Внутренние оповещения о сбоях опроса, отправки или диспатча"
|
||||||
},
|
},
|
||||||
"webhookLogs": {
|
"webhookLogs": {
|
||||||
"title": "Последние запросы",
|
"title": "Последние запросы",
|
||||||
@@ -1324,6 +1550,30 @@
|
|||||||
"applyLater": "Применить позже",
|
"applyLater": "Применить позже",
|
||||||
"restartNow": "Перезапустить сейчас",
|
"restartNow": "Перезапустить сейчас",
|
||||||
"restartingTitle": "Перезапуск бэкенда…",
|
"restartingTitle": "Перезапуск бэкенда…",
|
||||||
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
|
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен.",
|
||||||
|
"countLabel": "бэкапов",
|
||||||
|
"scheduleOn": "Авто · каждые {h}ч",
|
||||||
|
"scheduleOff": "Авто-бэкап выключен",
|
||||||
|
"lastBackup": "Последний {ago}",
|
||||||
|
"never": "ещё нет бэкапов",
|
||||||
|
"totalSize": "всего {size}",
|
||||||
|
"dropZone": "Перетащите JSON-бэкап сюда или нажмите для выбора",
|
||||||
|
"dropZoneActive": "Отпустите для загрузки",
|
||||||
|
"changeFile": "Сменить файл",
|
||||||
|
"catGroupIdentity": "Идентичность и маршрутизация",
|
||||||
|
"catGroupNotif": "Уведомления",
|
||||||
|
"catGroupCmd": "Команды",
|
||||||
|
"catGroupSystem": "Система",
|
||||||
|
"stepCategories": "Что включить",
|
||||||
|
"stepSecrets": "Обработка секретов",
|
||||||
|
"stepDownload": "Скачать",
|
||||||
|
"stepFile": "Выберите файл",
|
||||||
|
"stepValidate": "Проверить содержимое",
|
||||||
|
"stepConflict": "При конфликте",
|
||||||
|
"stepApply": "Применить",
|
||||||
|
"tagScheduled": "по расписанию",
|
||||||
|
"tagManual": "вручную",
|
||||||
|
"tagSecrets": "с секретами",
|
||||||
|
"validateFirst": "Сначала проверьте файл, чтобы включить импорт"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ProviderDescriptor } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge self-monitoring provider descriptor.
|
||||||
|
*
|
||||||
|
* The bridge_self provider has no remote URL and no credentials. The only
|
||||||
|
* configuration surface is the three thresholds below, used by the server
|
||||||
|
* to decide when an internal failure deserves a notification.
|
||||||
|
*
|
||||||
|
* Exactly one bridge_self provider exists per user, auto-seeded on user
|
||||||
|
* creation (see ``packages/server/src/notify_bridge_server/database/seeds.py``).
|
||||||
|
*/
|
||||||
|
export const bridgeSelfDescriptor: ProviderDescriptor = {
|
||||||
|
type: 'bridge_self',
|
||||||
|
defaultName: 'Bridge Self-Monitoring',
|
||||||
|
icon: 'mdiAlertCircleOutline',
|
||||||
|
hasUrl: false,
|
||||||
|
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: 'poll_failure_threshold',
|
||||||
|
configKey: 'poll_failure_threshold',
|
||||||
|
label: 'providers.bridgeSelfPollThreshold',
|
||||||
|
type: 'number',
|
||||||
|
optional: true,
|
||||||
|
min: 1,
|
||||||
|
defaultValue: 3,
|
||||||
|
hint: 'providers.bridgeSelfPollThresholdHint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deferred_backlog_threshold',
|
||||||
|
configKey: 'deferred_backlog_threshold',
|
||||||
|
label: 'providers.bridgeSelfDeferredThreshold',
|
||||||
|
type: 'number',
|
||||||
|
optional: true,
|
||||||
|
min: 1,
|
||||||
|
defaultValue: 100,
|
||||||
|
hint: 'providers.bridgeSelfDeferredThresholdHint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'target_failure_threshold',
|
||||||
|
configKey: 'target_failure_threshold',
|
||||||
|
label: 'providers.bridgeSelfTargetThreshold',
|
||||||
|
type: 'number',
|
||||||
|
optional: true,
|
||||||
|
min: 1,
|
||||||
|
defaultValue: 5,
|
||||||
|
hint: 'providers.bridgeSelfTargetThresholdHint',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
buildConfig(form) {
|
||||||
|
const toInt = (raw: unknown, fallback: number): number => {
|
||||||
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
poll_failure_threshold: toInt(form.poll_failure_threshold, 3),
|
||||||
|
deferred_backlog_threshold: toInt(form.deferred_backlog_threshold, 100),
|
||||||
|
target_failure_threshold: toInt(form.target_failure_threshold, 5),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
hasConfigChanged(form, existing) {
|
||||||
|
const toInt = (raw: unknown, fallback: number): number => {
|
||||||
|
const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10);
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : fallback;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
toInt(form.poll_failure_threshold, 3) !== toInt(existing.poll_failure_threshold, 3) ||
|
||||||
|
toInt(form.deferred_backlog_threshold, 100) !== toInt(existing.deferred_backlog_threshold, 100) ||
|
||||||
|
toInt(form.target_failure_threshold, 5) !== toInt(existing.target_failure_threshold, 5)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
eventFields: [
|
||||||
|
{
|
||||||
|
key: 'track_bridge_self_poll_failures',
|
||||||
|
label: 'trackingConfig.bridgeSelfPollFailures',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track_bridge_self_deferred_backlog',
|
||||||
|
label: 'trackingConfig.bridgeSelfDeferredBacklog',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track_bridge_self_target_failures',
|
||||||
|
label: 'trackingConfig.bridgeSelfTargetFailures',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
collectionMeta: null,
|
||||||
|
webhookBased: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ProviderDescriptor } from './types';
|
||||||
|
|
||||||
|
export const homeAssistantDescriptor: ProviderDescriptor = {
|
||||||
|
type: 'home_assistant',
|
||||||
|
defaultName: 'Home Assistant',
|
||||||
|
icon: 'mdiHomeAssistant',
|
||||||
|
hasUrl: true,
|
||||||
|
urlPlaceholder: 'http://homeassistant.local:8123',
|
||||||
|
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: 'access_token', configKey: 'access_token',
|
||||||
|
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
|
||||||
|
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'verify_tls', configKey: 'verify_tls',
|
||||||
|
label: 'providers.haVerifyTls',
|
||||||
|
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
buildConfig(form, editing) {
|
||||||
|
const config: Record<string, unknown> = { url: form.url };
|
||||||
|
if (form.access_token) config.access_token = form.access_token;
|
||||||
|
// Coerce truthy/falsy form values to a real boolean. The toggle
|
||||||
|
// control binds to `checked`, so this is normally already a bool,
|
||||||
|
// but legacy form state may carry the string defaults.
|
||||||
|
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
|
||||||
|
if (!editing && !form.access_token) {
|
||||||
|
return { config, error: 'providers.haAccessTokenRequired' };
|
||||||
|
}
|
||||||
|
return { config };
|
||||||
|
},
|
||||||
|
|
||||||
|
hasConfigChanged(form, existing) {
|
||||||
|
const existingVerify = existing.verify_tls !== false;
|
||||||
|
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
|
||||||
|
return (
|
||||||
|
form.url !== (existing.url || '') ||
|
||||||
|
!!form.access_token ||
|
||||||
|
existingVerify !== formVerify
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
eventFields: [
|
||||||
|
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
|
||||||
|
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
|
||||||
|
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
|
||||||
|
{
|
||||||
|
key: 'track_ha_event_fired',
|
||||||
|
label: 'trackingConfig.haEventFired',
|
||||||
|
default: false,
|
||||||
|
hint: 'trackingConfig.haEventFiredHint',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// entity_glob / domain_allowlist tag-style filters. Stored on the
|
||||||
|
// tracker's `filters` JSON column (not the flat form root) — the
|
||||||
|
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
|
||||||
|
// than a picker, and `filterKey` routes the value into
|
||||||
|
// `tracker.filters[filterKey]` at save time.
|
||||||
|
userFilters: [
|
||||||
|
{
|
||||||
|
key: 'entity_glob',
|
||||||
|
filterKey: 'entity_glob',
|
||||||
|
inputMode: 'tags',
|
||||||
|
label: 'notificationTracker.haEntityGlob',
|
||||||
|
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
|
||||||
|
icon: 'mdiAsterisk',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'domain_allowlist',
|
||||||
|
filterKey: 'domain_allowlist',
|
||||||
|
inputMode: 'tags',
|
||||||
|
label: 'notificationTracker.haDomainAllowlist',
|
||||||
|
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
|
||||||
|
icon: 'mdiTagOutline',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
collectionMeta: {
|
||||||
|
label: 'notificationTracker.entities',
|
||||||
|
icon: 'mdiViewList',
|
||||||
|
placeholder: 'notificationTracker.selectEntities',
|
||||||
|
countLabel: 'notificationTracker.entities_count',
|
||||||
|
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (col.domain) parts.push(col.domain);
|
||||||
|
if (col.state) parts.push(col.state);
|
||||||
|
if (parts.length === 0) return col.entity_id || col.id || '';
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
icon: 'mdiImageMultiple',
|
icon: 'mdiImageMultiple',
|
||||||
hasUrl: true,
|
hasUrl: true,
|
||||||
urlPlaceholder: undefined, // uses generic i18n placeholder
|
urlPlaceholder: undefined, // uses generic i18n placeholder
|
||||||
|
supportsAutoOrganize: true,
|
||||||
|
|
||||||
configFields: [
|
configFields: [
|
||||||
{
|
{
|
||||||
@@ -113,6 +114,17 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Periodic summaries / scheduled picks / memories / quiet hours all live on
|
||||||
|
// the linked tracking & template configs — surface that connection on the
|
||||||
|
// tracker form so users don't need to read docs to find them.
|
||||||
|
featureDiscoveryHint: {
|
||||||
|
messageKey: 'notificationTracker.featureDiscovery',
|
||||||
|
ctas: [
|
||||||
|
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
|
||||||
|
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
||||||
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
||||||
if (newIds.length === 0) return { proceed: true };
|
if (newIds.length === 0) return { proceed: true };
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { schedulerDescriptor } from './scheduler';
|
|||||||
import { nutDescriptor } from './nut';
|
import { nutDescriptor } from './nut';
|
||||||
import { googlePhotosDescriptor } from './google-photos';
|
import { googlePhotosDescriptor } from './google-photos';
|
||||||
import { webhookDescriptor } from './webhook';
|
import { webhookDescriptor } from './webhook';
|
||||||
|
import { homeAssistantDescriptor } from './home-assistant';
|
||||||
|
import { bridgeSelfDescriptor } from './bridge-self';
|
||||||
|
|
||||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||||
['immich', immichDescriptor],
|
['immich', immichDescriptor],
|
||||||
@@ -22,6 +24,8 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
|||||||
['nut', nutDescriptor],
|
['nut', nutDescriptor],
|
||||||
['google_photos', googlePhotosDescriptor],
|
['google_photos', googlePhotosDescriptor],
|
||||||
['webhook', webhookDescriptor],
|
['webhook', webhookDescriptor],
|
||||||
|
['home_assistant', homeAssistantDescriptor],
|
||||||
|
['bridge_self', bridgeSelfDescriptor],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface ConfigField {
|
|||||||
configKey?: string;
|
configKey?: string;
|
||||||
/** i18n key for the field label. */
|
/** i18n key for the field label. */
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'password' | 'number' | 'grid-select';
|
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
|
||||||
/** Grid-select item source function name from grid-items.ts. */
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
gridItems?: string;
|
gridItems?: string;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
@@ -123,17 +123,30 @@ export interface CollectionMeta {
|
|||||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares a filter that picks user identities from the provider's known
|
* Declares a filter rendered on the tracker form. Two input modes:
|
||||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
*
|
||||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
* * ``picker`` (default) — populated from the provider's ``/users``
|
||||||
* `tracker.filters[key]`.
|
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
|
||||||
|
* allowlists / blocklists where the valid values are known.
|
||||||
|
* * ``tags`` — free-text chip input. Used for glob patterns and other
|
||||||
|
* filter values that aren't enumerable in advance.
|
||||||
|
*
|
||||||
|
* Either way the picked values are stored as ``string[]`` under
|
||||||
|
* ``tracker.filters[filterKey ?? key]``.
|
||||||
*/
|
*/
|
||||||
export interface UserFilterMeta {
|
export interface UserFilterMeta {
|
||||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
/** Form field key — used internally for binding. */
|
||||||
key: string;
|
key: string;
|
||||||
/** i18n key for the label rendered above the picker. */
|
/**
|
||||||
|
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
|
||||||
|
* omitted (backward compat with the original sender allowlist usage).
|
||||||
|
*/
|
||||||
|
filterKey?: string;
|
||||||
|
/** ``picker`` (default) or ``tags`` for free-text chip input. */
|
||||||
|
inputMode?: 'picker' | 'tags';
|
||||||
|
/** i18n key for the label rendered above the input. */
|
||||||
label: string;
|
label: string;
|
||||||
/** i18n key for the picker placeholder. */
|
/** i18n key for the placeholder (picker dropdown or chip input). */
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
/** MDI icon shown on chips and dropdown rows. */
|
/** MDI icon shown on chips and dropdown rows. */
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -183,6 +196,31 @@ export interface ProviderDescriptor {
|
|||||||
/** Whether this provider stores incoming payload history for debugging. */
|
/** Whether this provider stores incoming payload history for debugging. */
|
||||||
payloadHistory?: boolean;
|
payloadHistory?: boolean;
|
||||||
|
|
||||||
|
// ── Capability flags ──
|
||||||
|
/**
|
||||||
|
* True when the provider exposes asset/people/album endpoints that the
|
||||||
|
* Auto-Organize action rule editor needs to render its people / album
|
||||||
|
* pickers (currently only Immich). Used in place of `type === 'immich'`
|
||||||
|
* checks per CLAUDE.md rule 8.
|
||||||
|
*/
|
||||||
|
supportsAutoOrganize?: boolean;
|
||||||
|
|
||||||
|
// ── Tracker-form discovery hint ──
|
||||||
|
/**
|
||||||
|
* Optional info banner shown on the TrackerForm to point users at related
|
||||||
|
* configuration pages they would otherwise have to discover from docs.
|
||||||
|
*
|
||||||
|
* The hint is rendered as a single i18n message followed by zero or more
|
||||||
|
* call-to-action links. ``ctas[].href`` may include ``{tracking_config_id}``
|
||||||
|
* / ``{template_config_id}`` placeholders that the form substitutes from
|
||||||
|
* the tracker's currently selected default-config IDs (or omits the
|
||||||
|
* ``?edit=...`` query when the value is 0).
|
||||||
|
*/
|
||||||
|
featureDiscoveryHint?: {
|
||||||
|
messageKey: string;
|
||||||
|
ctas?: Array<{ href: string; labelKey: string; icon?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Provider-specific hooks ──
|
// ── Provider-specific hooks ──
|
||||||
/**
|
/**
|
||||||
* Called after collection selection changes (before save).
|
* Called after collection selection changes (before save).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
CommandTemplateConfig,
|
CommandTemplateConfig,
|
||||||
CommandTracker,
|
CommandTracker,
|
||||||
Action,
|
Action,
|
||||||
|
ReleaseStatus,
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
|
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
|
||||||
@@ -112,6 +113,74 @@ export const capabilitiesCache = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/** Configured external base URL — used to render absolute webhook URLs.
|
||||||
|
* Available to all authenticated users. Empty string when unset. */
|
||||||
|
export const externalUrlCache = (() => {
|
||||||
|
let data = $state<string>('');
|
||||||
|
let fetchedAt = $state(0);
|
||||||
|
let inflight: Promise<string> | null = null;
|
||||||
|
const TTL = 300_000;
|
||||||
|
return {
|
||||||
|
get value() { return data; },
|
||||||
|
invalidate() { fetchedAt = 0; },
|
||||||
|
async fetch(force = false): Promise<string> {
|
||||||
|
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await api<{ external_url: string }>('/settings/external-url');
|
||||||
|
data = (res?.external_url || '').replace(/\/+$/, '');
|
||||||
|
fetchedAt = Date.now();
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** 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. */
|
/** Supported template locales — fetched from app settings. */
|
||||||
export const supportedLocalesCache = (() => {
|
export const supportedLocalesCache = (() => {
|
||||||
let data = $state<string[]>(['en', 'ru']);
|
let data = $state<string[]>(['en', 'ru']);
|
||||||
@@ -164,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate all entity caches. Useful on logout.
|
* 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 {
|
export function clearAllCaches(): void {
|
||||||
Object.values(allCaches).forEach(c => c.clear());
|
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 }> {
|
export interface EntityCache<T extends { id: number }> {
|
||||||
/** Reactive list of cached entities. */
|
/** Reactive list of cached entities. */
|
||||||
readonly items: T[];
|
readonly items: T[];
|
||||||
/** True only during the very first fetch (no cached data yet). */
|
/**
|
||||||
|
* True only during the very first fetch — when there is no cached data
|
||||||
|
* to show yet. Background re-fetches keep `loading` false so consumers
|
||||||
|
* keep rendering the previous list and don't flash a spinner; observe
|
||||||
|
* `refreshing` instead if a subtle indicator is needed.
|
||||||
|
*/
|
||||||
readonly loading: boolean;
|
readonly loading: boolean;
|
||||||
|
/**
|
||||||
|
* True during any non-first fetch (cached items already populated).
|
||||||
|
* Lets consumers distinguish "show skeleton" (loading) from "show subtle
|
||||||
|
* shimmer/disabled state" (refreshing) without sharing one flag.
|
||||||
|
*/
|
||||||
|
readonly refreshing: boolean;
|
||||||
/** Timestamp of last successful fetch. */
|
/** Timestamp of last successful fetch. */
|
||||||
readonly fetchedAt: number;
|
readonly fetchedAt: number;
|
||||||
/** Fetch entities — returns cached data if fresh, else hits network. */
|
/** Fetch entities — returns cached data if fresh, else hits network. */
|
||||||
@@ -43,6 +54,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
): EntityCache<T> {
|
): EntityCache<T> {
|
||||||
let _items = $state<T[]>([]);
|
let _items = $state<T[]>([]);
|
||||||
let _loading = $state(false);
|
let _loading = $state(false);
|
||||||
|
let _refreshing = $state(false);
|
||||||
let _fetchedAt = $state(0);
|
let _fetchedAt = $state(0);
|
||||||
|
|
||||||
function isFresh(): boolean {
|
function isFresh(): boolean {
|
||||||
@@ -56,8 +68,12 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
const existing = inflightRequests.get(endpoint);
|
const existing = inflightRequests.get(endpoint);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// First-load vs background-refresh state. We split these so consumers
|
||||||
|
// can keep the previous list visible during a re-fetch (refreshing)
|
||||||
|
// instead of flashing a spinner placeholder (loading).
|
||||||
const isFirstLoad = _fetchedAt === 0;
|
const isFirstLoad = _fetchedAt === 0;
|
||||||
if (isFirstLoad) _loading = true;
|
if (isFirstLoad) _loading = true;
|
||||||
|
else _refreshing = true;
|
||||||
|
|
||||||
const request = api<T[]>(endpoint)
|
const request = api<T[]>(endpoint)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -67,6 +83,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
_refreshing = false;
|
||||||
inflightRequests.delete(endpoint);
|
inflightRequests.delete(endpoint);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +121,7 @@ export function createEntityCache<T extends { id: number }>(
|
|||||||
return {
|
return {
|
||||||
get items() { return _items; },
|
get items() { return _items; },
|
||||||
get loading() { return _loading; },
|
get loading() { return _loading; },
|
||||||
|
get refreshing() { return _refreshing; },
|
||||||
get fetchedAt() { return _fetchedAt; },
|
get fetchedAt() { return _fetchedAt; },
|
||||||
fetch,
|
fetch,
|
||||||
invalidate,
|
invalidate,
|
||||||
|
|||||||
@@ -26,15 +26,14 @@ function loadFromStorage(): void {
|
|||||||
loadFromStorage();
|
loadFromStorage();
|
||||||
|
|
||||||
export const globalProviderFilter = {
|
export const globalProviderFilter = {
|
||||||
get id() {
|
/**
|
||||||
// If providers are loaded and the stored ID doesn't match any, auto-clear
|
* Pure getter — returns whatever was last stored, never mutates. Stale-ID
|
||||||
if (_providerId != null && providersCache.items.length > 0 &&
|
* reconciliation against `providersCache` is the responsibility of a
|
||||||
!providersCache.items.some(p => p.id === _providerId)) {
|
* one-time `$effect` in `+layout.svelte` (see `reconcileStaleProviderId`),
|
||||||
globalProviderFilter.clear();
|
* because writing during read inside a `$state`-derived getter triggers
|
||||||
return null;
|
* Svelte 5's `state_unsafe_mutation` warning.
|
||||||
}
|
*/
|
||||||
return _providerId;
|
get id() { return _providerId; },
|
||||||
},
|
|
||||||
get initialized() { return _initialized; },
|
get initialized() { return _initialized; },
|
||||||
|
|
||||||
set(id: number | null) {
|
set(id: number | null) {
|
||||||
@@ -52,9 +51,24 @@ export const globalProviderFilter = {
|
|||||||
this.set(null);
|
this.set(null);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the stored provider ID if it no longer matches any item in the
|
||||||
|
* providers cache. Safe to call from a `$effect` after the cache has been
|
||||||
|
* fetched. Returns true when reconciliation actually changed state, so the
|
||||||
|
* caller can short-circuit follow-up work.
|
||||||
|
*/
|
||||||
|
reconcileWithCache(): boolean {
|
||||||
|
if (_providerId != null && providersCache.items.length > 0 &&
|
||||||
|
!providersCache.items.some(p => p.id === _providerId)) {
|
||||||
|
this.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
/** The currently selected provider object (reactive). */
|
/** The currently selected provider object (reactive). */
|
||||||
get provider() {
|
get provider() {
|
||||||
const id = this.id; // triggers stale-ID auto-clear
|
const id = _providerId;
|
||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
return providersCache.items.find(p => p.id === id) ?? null;
|
return providersCache.items.find(p => p.id === id) ?? null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -212,16 +212,51 @@ export interface TemplateConfig {
|
|||||||
created_at: string;
|
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 {
|
export interface EventLog {
|
||||||
id: number;
|
id: number;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
collection_id: string;
|
collection_id: string;
|
||||||
collection_name: string;
|
collection_name: string;
|
||||||
|
tracker_id?: number | null;
|
||||||
tracker_name: string;
|
tracker_name: string;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
provider_id: number | null;
|
provider_id: number | null;
|
||||||
|
action_id?: number | null;
|
||||||
|
action_name?: string;
|
||||||
|
command_tracker_id?: number | null;
|
||||||
|
command_tracker_name?: string;
|
||||||
|
telegram_bot_id?: number | null;
|
||||||
|
bot_name?: string;
|
||||||
assets_count: number;
|
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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,4 +372,38 @@ export interface DashboardStatus {
|
|||||||
total_events: number;
|
total_events: number;
|
||||||
recent_events: EventLog[];
|
recent_events: EventLog[];
|
||||||
command_trackers?: number;
|
command_trackers?: number;
|
||||||
|
/** Provider name → total event count across ALL events matching the
|
||||||
|
* current filters (ignores pagination). Powers the "On watch" deck
|
||||||
|
* when the user opts out of page-scoped stats. */
|
||||||
|
provider_event_counts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
||||||
|
|
||||||
|
export 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 { onMount } from 'svelte';
|
||||||
import { fade, slide } from 'svelte/transition';
|
import { fade, slide } from 'svelte/transition';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { api } from '$lib/api';
|
import { api, errMsg } from '$lib/api';
|
||||||
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
providersCache, notificationTrackersCache, trackingConfigsCache,
|
providersCache, notificationTrackersCache, trackingConfigsCache,
|
||||||
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
|
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
|
||||||
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
|
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
|
||||||
matrixBotsCache, targetsCache,
|
matrixBotsCache, targetsCache, releaseStatusCache,
|
||||||
} from '$lib/stores/caches.svelte';
|
} from '$lib/stores/caches.svelte';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
@@ -31,34 +31,55 @@
|
|||||||
|
|
||||||
let allProviders = $derived(providersCache.items);
|
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([
|
let providerFilterItems = $derived([
|
||||||
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
||||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||||
]);
|
]);
|
||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
// One-way: the store is the source of truth, the filter widget displays it.
|
||||||
let _syncingFilter = false;
|
// IconGridSelect mutations route through `onChange` (see template) so we
|
||||||
|
// never need a paired `$effect` to mirror the local <-> store value, which
|
||||||
|
// previously required a `_syncingFilter` reentrancy flag.
|
||||||
|
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
|
||||||
|
|
||||||
// Reserve the provider-filter row from first paint until the cache resolves.
|
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||||
// Without this, the row appears mid-paint and pushes nav items down on every
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||||
// hard reload — the most visible "jump" the user reported.
|
// hard reload — the most visible "jump" the user reported.
|
||||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||||
|
|
||||||
// Sync filter value → store
|
// Reconcile a stale persisted provider ID against the freshly-loaded
|
||||||
|
// providers cache. Lives here (not in the store getter) because writing
|
||||||
|
// `_providerId` from a `$state`-derived getter triggers Svelte's
|
||||||
|
// `state_unsafe_mutation`. Runs once per cache refresh.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
// Track `fetchedAt` so we re-run after the cache loads.
|
||||||
if (_syncingFilter) return;
|
void providersCache.fetchedAt;
|
||||||
globalProviderFilter.set(v === 0 ? null : v);
|
void providersCache.items.length;
|
||||||
|
globalProviderFilter.reconcileWithCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync store → filter value (handles auto-clear of stale IDs)
|
function setProviderFilter(v: string | number) {
|
||||||
$effect(() => {
|
const num = typeof v === 'number' ? v : Number(v);
|
||||||
const storeId = globalProviderFilter.id;
|
globalProviderFilter.set(num === 0 ? null : num);
|
||||||
if (storeId === null && providerFilterValue !== 0) {
|
}
|
||||||
_syncingFilter = true;
|
|
||||||
providerFilterValue = 0;
|
// Collapsed-rail filter cycles through providers via the same setter so the
|
||||||
_syncingFilter = false;
|
// store stays the single write path.
|
||||||
}
|
function cycleProviderFilter() {
|
||||||
});
|
const ids = [0, ...allProviders.map(p => p.id)];
|
||||||
|
const idx = ids.indexOf(providerFilterValue);
|
||||||
|
setProviderFilter(ids[(idx + 1) % ids.length]);
|
||||||
|
}
|
||||||
|
|
||||||
let showPasswordForm = $state(false);
|
let showPasswordForm = $state(false);
|
||||||
let redirecting = $state(false);
|
let redirecting = $state(false);
|
||||||
@@ -80,7 +101,7 @@
|
|||||||
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
|
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
|
||||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
} catch (err: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read persisted UI state synchronously so first paint already matches the
|
// Read persisted UI state synchronously so first paint already matches the
|
||||||
@@ -306,6 +327,7 @@
|
|||||||
emailBotsCache.fetch(),
|
emailBotsCache.fetch(),
|
||||||
matrixBotsCache.fetch(),
|
matrixBotsCache.fetch(),
|
||||||
targetsCache.fetch(),
|
targetsCache.fetch(),
|
||||||
|
releaseStatusCache.fetch(),
|
||||||
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
|
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -401,7 +423,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
Notify Bridge
|
Notify Bridge
|
||||||
</h1>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -421,18 +456,14 @@
|
|||||||
{#if showProviderFilter}
|
{#if showProviderFilter}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={cycleProviderFilter}
|
||||||
const ids = [0, ...allProviders.map(p => p.id)];
|
|
||||||
const idx = ids.indexOf(providerFilterValue);
|
|
||||||
providerFilterValue = ids[(idx + 1) % ids.length];
|
|
||||||
}}
|
|
||||||
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
|
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
|
||||||
title={globalProviderFilter.provider?.name || t('common.allProviders')}
|
title={globalProviderFilter.provider?.name || t('common.allProviders')}
|
||||||
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||||
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
|
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 3)} compact />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -468,6 +499,7 @@
|
|||||||
<a
|
<a
|
||||||
href={child.href}
|
href={child.href}
|
||||||
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
|
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
|
||||||
|
aria-current={isActive(child.href) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{#if isActive(child.href)}
|
{#if isActive(child.href)}
|
||||||
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
@@ -487,6 +519,7 @@
|
|||||||
href={entry.href}
|
href={entry.href}
|
||||||
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
|
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
|
||||||
title={collapsed ? t(entry.key) : ''}
|
title={collapsed ? t(entry.key) : ''}
|
||||||
|
aria-current={isActive(entry.href) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{#if isActive(entry.href)}
|
{#if isActive(entry.href)}
|
||||||
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
@@ -570,6 +603,7 @@
|
|||||||
<NavIcon name="mdiMagnify" size={20} />
|
<NavIcon name="mdiMagnify" size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
|
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
|
||||||
|
aria-expanded={mobileMoreOpen}
|
||||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||||
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||||
<NavIcon name="mdiDotsHorizontal" size={20} />
|
<NavIcon name="mdiDotsHorizontal" size={20} />
|
||||||
@@ -584,7 +618,7 @@
|
|||||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||||
{#if allProviders.length >= 1}
|
{#if allProviders.length >= 1}
|
||||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -772,6 +806,40 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
font-weight: 500;
|
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 {
|
.brand-orb {
|
||||||
width: 32px; height: 32px;
|
width: 32px; height: 32px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
import EventChart from '$lib/components/EventChart.svelte';
|
import EventChart from '$lib/components/EventChart.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerStatsModeItems, providerDefaultIcon } from '$lib/grid-items';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
|
|
||||||
import type { DashboardStatus } from '$lib/types';
|
import type { DashboardStatus, EventLog } from '$lib/types';
|
||||||
|
|
||||||
const SECTIONS_KEY = 'dashboard_section_state';
|
const SECTIONS_KEY = 'dashboard_section_state';
|
||||||
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
|
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
|
||||||
@@ -75,10 +76,77 @@
|
|||||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
return stored ? parseInt(stored, 10) || 10 : 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "On watch" provider deck stats scope. ``'page'`` = derive counts from
|
||||||
|
// the events visible on the current page (legacy behavior); ``'all'`` =
|
||||||
|
// use the server-aggregated ``provider_event_counts`` map covering every
|
||||||
|
// event that matches the active filters.
|
||||||
|
const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode';
|
||||||
|
function loadProviderStatsMode(): string {
|
||||||
|
if (typeof localStorage === 'undefined') return 'page';
|
||||||
|
const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY);
|
||||||
|
return stored === 'all' ? 'all' : 'page';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
|
||||||
|
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
||||||
|
// this whitelist in sync with that helper so a stale localStorage
|
||||||
|
// value can't smuggle in an unsupported interval (e.g. someone
|
||||||
|
// hand-edits to 1).
|
||||||
|
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
|
||||||
|
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
|
||||||
|
function loadRefreshSeconds(): number {
|
||||||
|
if (typeof localStorage === 'undefined') return 0;
|
||||||
|
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
|
||||||
|
const v = stored ? parseInt(stored, 10) : 0;
|
||||||
|
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
let eventsLimit = $state(loadEventsPerPage());
|
let eventsLimit = $state(loadEventsPerPage());
|
||||||
let eventsOffset = $state(0);
|
let eventsOffset = $state(0);
|
||||||
let eventsLoading = $state(false);
|
let eventsLoading = $state(false);
|
||||||
let confirmClearEvents = $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
|
||||||
|
// (~600ms of fade-up per row) which reads as the panel "reconstructing".
|
||||||
|
let eventsAnimated = $state(false);
|
||||||
|
|
||||||
|
// Auto-refresh ticker — re-creates the interval whenever the user
|
||||||
|
// changes the cadence. ``$effect`` returns a cleanup that fires on
|
||||||
|
// destroy AND on any tracked dep change, so the prior timer is torn
|
||||||
|
// down before a new one starts.
|
||||||
|
$effect(() => {
|
||||||
|
if (refreshSeconds <= 0) return;
|
||||||
|
// Pause auto-refresh when the tab is hidden so we don't burn API
|
||||||
|
// calls on a tab the user can't see — we'll catch up on the next
|
||||||
|
// visibility flip via ``visibilitychange`` below.
|
||||||
|
const tick = () => {
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
|
loadEvents({ silent: true });
|
||||||
|
loadChart();
|
||||||
|
};
|
||||||
|
const handle = setInterval(tick, refreshSeconds * 1000);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist whenever the cadence changes (the IconGridSelect mutates
|
||||||
|
// ``refreshSeconds`` directly via bind:value).
|
||||||
|
let _refreshHydrated = false;
|
||||||
|
$effect(() => {
|
||||||
|
const v = refreshSeconds;
|
||||||
|
if (!_refreshHydrated) { _refreshHydrated = true; return; }
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist the provider deck stats mode the same way.
|
||||||
|
let _providerStatsHydrated = false;
|
||||||
|
$effect(() => {
|
||||||
|
const v = providerStatsMode;
|
||||||
|
if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; }
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v);
|
||||||
|
});
|
||||||
|
|
||||||
async function clearEvents() {
|
async function clearEvents() {
|
||||||
try {
|
try {
|
||||||
@@ -119,22 +187,54 @@
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
/** Reload the events panel.
|
||||||
eventsLoading = true;
|
*
|
||||||
|
* ``silent`` is set by the auto-refresh ticker so the loading
|
||||||
|
* placeholder doesn't flash and the row list isn't disturbed when
|
||||||
|
* nothing actually changed. We diff the new payload against the
|
||||||
|
* current ``status`` and reuse the existing ``recent_events`` array
|
||||||
|
* reference when the ID list is identical — that lets Svelte's keyed
|
||||||
|
* ``{#each}`` skip its diff entirely instead of patching every row.
|
||||||
|
*/
|
||||||
|
async function loadEvents(opts: { silent?: boolean } = {}) {
|
||||||
|
if (!opts.silent) eventsLoading = true;
|
||||||
try {
|
try {
|
||||||
const params = buildFilterParams();
|
const params = buildFilterParams();
|
||||||
params.set('sort', filterSort);
|
params.set('sort', filterSort);
|
||||||
params.set('limit', String(eventsLimit));
|
params.set('limit', String(eventsLimit));
|
||||||
params.set('offset', String(eventsOffset));
|
params.set('offset', String(eventsOffset));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
||||||
|
|
||||||
|
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
|
||||||
|
// Nothing changed in the visible page. Update only the
|
||||||
|
// out-of-band counts so the header and pager stay accurate;
|
||||||
|
// keep the existing array reference so no row re-renders.
|
||||||
|
status = {
|
||||||
|
...status,
|
||||||
|
providers: next.providers,
|
||||||
|
trackers: next.trackers,
|
||||||
|
targets: next.targets,
|
||||||
|
total_events: next.total_events,
|
||||||
|
command_trackers: next.command_trackers,
|
||||||
|
provider_event_counts: next.provider_event_counts,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = next;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error = err instanceof Error ? err.message : t('common.error');
|
error = err instanceof Error ? err.message : t('common.error');
|
||||||
} finally {
|
} finally {
|
||||||
eventsLoading = false;
|
if (!opts.silent) eventsLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadChart() {
|
async function loadChart() {
|
||||||
try {
|
try {
|
||||||
const params = buildFilterParams();
|
const params = buildFilterParams();
|
||||||
@@ -204,14 +304,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable stagger entry animation once the first non-empty list has
|
||||||
|
// rendered + had time to play. Subsequent pagination/filter reloads
|
||||||
|
// then settle in place instead of re-running the cascade.
|
||||||
|
$effect(() => {
|
||||||
|
if (eventsAnimated) return;
|
||||||
|
if (!status?.recent_events?.length) return;
|
||||||
|
const handle = setTimeout(() => { eventsAnimated = true; }, 700);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
});
|
||||||
|
|
||||||
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
||||||
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
||||||
: displayProviders);
|
: displayProviders);
|
||||||
|
|
||||||
// === Provider deck — derive activity counts from recent events ===
|
// === Provider deck — derive activity counts from recent events ===
|
||||||
|
//
|
||||||
|
// ``providerStatsMode`` controls the scope: ``'page'`` derives counts
|
||||||
|
// from the visible page (legacy), ``'all'`` uses the server-aggregated
|
||||||
|
// ``provider_event_counts`` map covering every event under the active
|
||||||
|
// filters regardless of pagination.
|
||||||
const providerEventCounts = $derived.by(() => {
|
const providerEventCounts = $derived.by(() => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
if (!status) return counts;
|
if (!status) return counts;
|
||||||
|
if (providerStatsMode === 'all' && status.provider_event_counts) {
|
||||||
|
for (const [name, total] of Object.entries(status.provider_event_counts)) {
|
||||||
|
if (!name) continue;
|
||||||
|
counts.set(name, total);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
for (const ev of status.recent_events) {
|
for (const ev of status.recent_events) {
|
||||||
const k = ev.provider_name || '';
|
const k = ev.provider_name || '';
|
||||||
if (!k) continue;
|
if (!k) continue;
|
||||||
@@ -360,6 +482,9 @@
|
|||||||
action_success: 'dashboard.actionSuccess',
|
action_success: 'dashboard.actionSuccess',
|
||||||
action_partial: 'dashboard.actionPartial',
|
action_partial: 'dashboard.actionPartial',
|
||||||
action_failed: 'dashboard.actionFailed',
|
action_failed: 'dashboard.actionFailed',
|
||||||
|
command_handled: 'dashboard.commandHandled',
|
||||||
|
command_rate_limited: 'dashboard.commandRateLimited',
|
||||||
|
command_failed: 'dashboard.commandFailed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventIcons: Record<string, string> = {
|
const eventIcons: Record<string, string> = {
|
||||||
@@ -367,6 +492,7 @@
|
|||||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||||
scheduled_message: 'mdiCalendarClock',
|
scheduled_message: 'mdiCalendarClock',
|
||||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||||
|
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aurora gradient palette per event type — used for the avatar tile
|
// Aurora gradient palette per event type — used for the avatar tile
|
||||||
@@ -380,6 +506,9 @@
|
|||||||
action_success: ['var(--color-mint)', 'var(--color-primary)'],
|
action_success: ['var(--color-mint)', 'var(--color-primary)'],
|
||||||
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
|
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
|
||||||
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||||
|
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
|
||||||
|
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
|
||||||
|
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAT_ACCENTS = [
|
const STAT_ACCENTS = [
|
||||||
@@ -554,6 +683,11 @@
|
|||||||
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
||||||
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
||||||
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
||||||
|
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
|
||||||
|
<IconGridSelect items={refreshIntervalItems()}
|
||||||
|
bind:value={refreshSeconds}
|
||||||
|
columns={5} compact />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet paginator()}
|
{#snippet paginator()}
|
||||||
@@ -588,17 +722,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if eventsLoading}
|
{#if status.recent_events.length === 0}
|
||||||
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
|
{#if eventsLoading}
|
||||||
{:else if status.recent_events.length === 0}
|
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
|
||||||
<div class="empty-state">
|
{:else}
|
||||||
<MdiIcon name="mdiCalendarBlank" size={36} />
|
<div class="empty-state">
|
||||||
<p>{t('dashboard.noEvents')}</p>
|
<MdiIcon name="mdiCalendarBlank" size={36} />
|
||||||
</div>
|
<p>{t('dashboard.noEvents')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="signal-list stagger-children">
|
<div class="signal-list"
|
||||||
{#each status.recent_events as event, i}
|
class:stagger-children={!eventsAnimated}
|
||||||
<div class="signal-row" style="animation-delay: {i * 60}ms;">
|
class:signal-list--reloading={eventsLoading}
|
||||||
|
aria-busy={eventsLoading}>
|
||||||
|
{#each status.recent_events as event, i (event.id)}
|
||||||
|
<button type="button" class="signal-row signal-row--clickable"
|
||||||
|
style={eventsAnimated ? '' : `animation-delay: ${i * 60}ms;`}
|
||||||
|
onclick={() => selectedEvent = event}
|
||||||
|
aria-label={t('events.detailTitle')}>
|
||||||
<div class="signal-avatar"
|
<div class="signal-avatar"
|
||||||
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
|
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
|
||||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
|
||||||
@@ -615,7 +757,60 @@
|
|||||||
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
|
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if event.tracker_name}
|
{#if event.details?.dispatch_status === 'deferred' && event.details?.deferred_until}
|
||||||
|
<span class="dispatch-badge dispatch-badge--deferred"
|
||||||
|
title={t('dashboard.deferredTitle')}>
|
||||||
|
<MdiIcon name="mdiPauseCircleOutline" size={12} />
|
||||||
|
{t('dashboard.heldUntil')} {timeShort(event.details.deferred_until)}
|
||||||
|
</span>
|
||||||
|
{:else if event.details?.dispatch_status === 'delivered_after_quiet_hours'}
|
||||||
|
<span class="dispatch-badge dispatch-badge--late"
|
||||||
|
title={t('dashboard.deliveredLateTitle')}>
|
||||||
|
<MdiIcon name="mdiClockCheckOutline" size={12} />
|
||||||
|
{t('dashboard.deliveredLate')}
|
||||||
|
</span>
|
||||||
|
{:else if event.details?.dispatch_status === 'deferred_then_dropped'}
|
||||||
|
<span class="dispatch-badge dispatch-badge--dropped"
|
||||||
|
title={t('dashboard.deferredThenDroppedTitle')}>
|
||||||
|
<MdiIcon name="mdiCloseCircleOutline" size={12} />
|
||||||
|
{t('dashboard.deferredThenDropped')}
|
||||||
|
</span>
|
||||||
|
{:else if event.details?.dispatch_status === 'deferred_then_failed'}
|
||||||
|
<span class="dispatch-badge dispatch-badge--dropped"
|
||||||
|
title={event.details?.reason ?? ''}>
|
||||||
|
<MdiIcon name="mdiAlertCircleOutline" size={12} />
|
||||||
|
{t('dashboard.deferredThenFailed')}
|
||||||
|
</span>
|
||||||
|
{:else if event.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
|
||||||
|
<span class="dispatch-badge dispatch-badge--dropped"
|
||||||
|
title={t('dashboard.suppressedNondeferrableTitle')}>
|
||||||
|
<MdiIcon name="mdiVolumeOff" size={12} />
|
||||||
|
{t('dashboard.suppressedQuietHours')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if event.event_type?.startsWith('command_')}
|
||||||
|
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
|
||||||
|
{@const issuerLabel = issuer
|
||||||
|
? (issuer.username ? '@' + issuer.username : [issuer.first_name, issuer.last_name].filter(Boolean).join(' ') || ('id ' + issuer.id))
|
||||||
|
: ''}
|
||||||
|
<div class="signal-trail">
|
||||||
|
{#if event.bot_name}
|
||||||
|
<span class="ch"><MdiIcon name="mdiRobotHappy" size={11} />{event.bot_name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if event.collection_id}
|
||||||
|
{#if event.bot_name}<span class="arrow">→</span>{/if}
|
||||||
|
<span class="ch"><MdiIcon name="mdiChatProcessing" size={11} />{event.collection_id}</span>
|
||||||
|
{/if}
|
||||||
|
{#if issuerLabel}
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="ch"><MdiIcon name="mdiAccount" size={11} />{issuerLabel}</span>
|
||||||
|
{/if}
|
||||||
|
{#if event.provider_name}
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if event.tracker_name}
|
||||||
<div class="signal-trail">
|
<div class="signal-trail">
|
||||||
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
|
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
|
||||||
{#if event.provider_name}
|
{#if event.provider_name}
|
||||||
@@ -629,7 +824,7 @@
|
|||||||
<b>{timeShort(event.created_at)}</b>
|
<b>{timeShort(event.created_at)}</b>
|
||||||
<small>{timeAgo(event.created_at)}</small>
|
<small>{timeAgo(event.created_at)}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -650,11 +845,18 @@
|
|||||||
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
|
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
|
||||||
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
|
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick={() => toggleSection('on_watch')}
|
<div class="panel-head-actions">
|
||||||
class="ghost-icon-btn"
|
<div class="w-32" title={t('dashboard.statsModeTitle')}>
|
||||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
<IconGridSelect items={providerStatsModeItems()}
|
||||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
bind:value={providerStatsMode}
|
||||||
</button>
|
columns={2} compact />
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick={() => toggleSection('on_watch')}
|
||||||
|
class="ghost-icon-btn"
|
||||||
|
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
||||||
|
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if sectionExpanded.on_watch}
|
{#if sectionExpanded.on_watch}
|
||||||
@@ -790,6 +992,8 @@
|
|||||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||||
|
|
||||||
|
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
HERO
|
HERO
|
||||||
@@ -1118,6 +1322,11 @@
|
|||||||
SIGNAL STREAM — events with routing trail
|
SIGNAL STREAM — events with routing trail
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
|
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
|
||||||
|
/* Soft dim while a page change / filter reload is in flight. We keep
|
||||||
|
the previous rows mounted (avoids the layout collapsing to a tiny
|
||||||
|
"Loading…" placeholder) and just nudge opacity so the swap feels
|
||||||
|
like a refresh rather than a teardown. */
|
||||||
|
.signal-list--reloading { opacity: 0.55; pointer-events: none; transition: opacity 0.15s ease; }
|
||||||
.signal-row {
|
.signal-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr auto;
|
grid-template-columns: 40px 1fr auto;
|
||||||
@@ -1129,6 +1338,20 @@
|
|||||||
}
|
}
|
||||||
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
|
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
|
||||||
.signal-row:hover { background: var(--color-glass-strong); }
|
.signal-row:hover { background: var(--color-glass-strong); }
|
||||||
|
/* Row is rendered as <button> for clickability — strip default chrome
|
||||||
|
and align children left like the prior <div> layout. */
|
||||||
|
.signal-row--clickable {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.signal-row--clickable:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
.signal-avatar {
|
.signal-avatar {
|
||||||
width: 40px; height: 40px;
|
width: 40px; height: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -1182,6 +1405,36 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.signal-trail .arrow { color: var(--color-muted-foreground); }
|
.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 {
|
.signal-when {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { actionsCache, providersCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
import ExecutionHistory from './ExecutionHistory.svelte';
|
import ExecutionHistory from './ExecutionHistory.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { Action, ActionRule } from '$lib/types';
|
import type { Action, ActionRule } from '$lib/types';
|
||||||
|
|
||||||
let allActions = $derived(actionsCache.items);
|
let allActions = $derived(actionsCache.items);
|
||||||
@@ -40,7 +41,19 @@
|
|||||||
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
|
function actionTypeLabel(at: string): string {
|
||||||
|
return at.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const provider = providers.find((p: any) => p.id === form.provider_id);
|
||||||
|
const at = actionTypeLabel(form.action_type || '');
|
||||||
|
form.name = provider ? `${provider.name} ${at}`.trim() : at || 'Action';
|
||||||
|
}
|
||||||
|
});
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
@@ -86,8 +99,8 @@
|
|||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('actions.loadError');
|
loadError = errMsg(err, t('actions.loadError'));
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +111,7 @@
|
|||||||
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
config: {}, schedule_type: 'interval', schedule_interval: 3600, schedule_cron: '',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null; showForm = true;
|
editing = null; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +123,7 @@
|
|||||||
schedule_interval: action.schedule_interval,
|
schedule_interval: action.schedule_interval,
|
||||||
schedule_cron: action.schedule_cron, enabled: action.enabled,
|
schedule_cron: action.schedule_cron, enabled: action.enabled,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = action.id; showForm = true;
|
editing = action.id; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +138,7 @@
|
|||||||
}
|
}
|
||||||
showForm = false; editing = null; actionsCache.invalidate(); await load();
|
showForm = false; editing = null; actionsCache.invalidate(); await load();
|
||||||
snackSuccess(t('actions.saved'));
|
snackSuccess(t('actions.saved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +151,7 @@
|
|||||||
await api(`/actions/${id}`, { method: 'DELETE' });
|
await api(`/actions/${id}`, { method: 'DELETE' });
|
||||||
actionsCache.invalidate(); await load();
|
actionsCache.invalidate(); await load();
|
||||||
snackSuccess(t('actions.deleted'));
|
snackSuccess(t('actions.deleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeAction(id: number, dryRun = false) {
|
async function executeAction(id: number, dryRun = false) {
|
||||||
@@ -150,7 +165,7 @@
|
|||||||
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
|
: `${t('actions.execute')}: ${affected} ${t('actions.affected')}`;
|
||||||
snackSuccess(msg);
|
snackSuccess(msg);
|
||||||
actionsCache.invalidate(); await load();
|
actionsCache.invalidate(); await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
executing = { ...executing, [id]: false };
|
executing = { ...executing, [id]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,13 +194,58 @@
|
|||||||
if (status === 'failed') return 'var(--color-error-fg)';
|
if (status === 'failed') return 'var(--color-error-fg)';
|
||||||
return 'var(--color-muted-foreground)';
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('actions.title')}
|
title={t('actions.title')}
|
||||||
emphasis={t('actions.titleEmphasis')}
|
emphasis={t('actions.titleEmphasis')}
|
||||||
description={t('actions.description')}
|
description={t('actions.description')}
|
||||||
crumb="Routing · Automation"
|
crumb={t('crumbs.routingAutomation')}
|
||||||
count={actions.length}
|
count={actions.length}
|
||||||
countLabel={t('actions.countLabel')}
|
countLabel={t('actions.countLabel')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -245,7 +305,7 @@
|
|||||||
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
|
<label for="act-name" class="block text-sm font-medium mb-1">{t('actions.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="act-name" bind:value={form.name} required
|
<input id="act-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,32 +369,35 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each actions as action}
|
{#each actions as action}
|
||||||
<Card hover entityId={action.id}>
|
<Card hover entityId={action.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div class="flex items-center gap-3">
|
<div class="list-row__identity">
|
||||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
|
||||||
<div>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="min-w-0">
|
||||||
<p class="font-medium">{action.name}</p>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
|
<p class="font-medium truncate">{action.name}</p>
|
||||||
</div>
|
<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 class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
|
</div>
|
||||||
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
|
||||||
<span>{formatSchedule(action)}</span>
|
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
|
||||||
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
<span>{formatSchedule(action)}</span>
|
||||||
{#if action.last_run_status}
|
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
|
||||||
<span style="color: {statusColor(action.last_run_status)}">
|
{#if action.last_run_status}
|
||||||
{action.last_run_status}
|
<span style="color: {statusColor(action.last_run_status)}">
|
||||||
</span>
|
{action.last_run_status}
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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')}
|
<IconButton icon="mdiPlay" title={t('actions.execute')}
|
||||||
onclick={() => executeAction(action.id)}
|
onclick={() => executeAction(action.id)}
|
||||||
disabled={executing[action.id]} />
|
disabled={executing[action.id]} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache } from '$lib/stores/caches.svelte';
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
|
import { getDescriptor } from '$lib/providers';
|
||||||
import type { ActionRule } from '$lib/types';
|
import type { ActionRule } from '$lib/types';
|
||||||
|
|
||||||
let { actionId, actionType, providerId }: { actionId: number; actionType: string; providerId: number } = $props();
|
let { actionId, actionType, providerId }: { actionId: number; actionType: string; providerId: number } = $props();
|
||||||
@@ -47,14 +48,16 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
|
rules = await api<ActionRule[]>(`/actions/${actionId}/rules`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProviderData() {
|
async function loadProviderData() {
|
||||||
if (actionType !== 'auto_organize') return;
|
if (actionType !== 'auto_organize') return;
|
||||||
const provider = providersCache.items.find((p: any) => p.id === providerId);
|
const provider = providersCache.items.find((p: any) => p.id === providerId);
|
||||||
if (!provider || provider.type !== 'immich') return;
|
if (!provider) return;
|
||||||
|
const descriptor = getDescriptor(provider.type);
|
||||||
|
if (!descriptor?.supportsAutoOrganize) return;
|
||||||
try {
|
try {
|
||||||
const [p, a] = await Promise.all([
|
const [p, a] = await Promise.all([
|
||||||
api<any>(`/providers/${providerId}/people`),
|
api<any>(`/providers/${providerId}/people`),
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
resetNewRule();
|
resetNewRule();
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleSaved'));
|
snackSuccess(t('actions.ruleSaved'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@
|
|||||||
});
|
});
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleSaved'));
|
snackSuccess(t('actions.ruleSaved'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRule(ruleId: number) {
|
async function deleteRule(ruleId: number) {
|
||||||
@@ -99,7 +102,7 @@
|
|||||||
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
|
await api(`/actions/${actionId}/rules/${ruleId}`, { method: 'DELETE' });
|
||||||
await loadRules();
|
await loadRules();
|
||||||
snackSuccess(t('actions.ruleDeleted'));
|
snackSuccess(t('actions.ruleDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRule(rule: ActionRule) {
|
async function toggleRule(rule: ActionRule) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
emailBotsCache.fetch(true),
|
emailBotsCache.fetch(true),
|
||||||
matrixBotsCache.fetch(true),
|
matrixBotsCache.fetch(true),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { EmailBot } from '$lib/types';
|
import type { EmailBot } from '$lib/types';
|
||||||
|
|
||||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||||
@@ -30,8 +31,40 @@
|
|||||||
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
smtp_username: '', smtp_password: '', smtp_use_tls: true,
|
||||||
});
|
});
|
||||||
let emailForm = $state(defaultEmailForm());
|
let emailForm = $state(defaultEmailForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
|
const DEFAULT_BOT_NAME = 'Email Bot';
|
||||||
|
$effect(() => {
|
||||||
|
if (showEmailForm && !nameManuallyEdited && !editingEmail) {
|
||||||
|
emailForm.name = DEFAULT_BOT_NAME;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
function editEmailBot(bot: EmailBot) {
|
||||||
emailForm = {
|
emailForm = {
|
||||||
name: bot.name, icon: bot.icon || '', email: bot.email,
|
name: bot.name, icon: bot.icon || '', email: bot.email,
|
||||||
@@ -39,6 +72,7 @@
|
|||||||
smtp_username: bot.smtp_username, smtp_password: '',
|
smtp_username: bot.smtp_username, smtp_password: '',
|
||||||
smtp_use_tls: bot.smtp_use_tls,
|
smtp_use_tls: bot.smtp_use_tls,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editingEmail = bot.id; showEmailForm = true;
|
editingEmail = bot.id; showEmailForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +88,8 @@
|
|||||||
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||||
snackSuccess(t('snack.emailBotCreated'));
|
snackSuccess(t('snack.emailBotCreated'));
|
||||||
}
|
}
|
||||||
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
|
emailForm = defaultEmailForm(); nameManuallyEdited = false; showEmailForm = false; editingEmail = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { emailSubmitting = false; }
|
finally { emailSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +99,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDeleteEmail = null; }
|
finally { confirmDeleteEmail = null; }
|
||||||
}
|
}
|
||||||
@@ -81,7 +115,7 @@
|
|||||||
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api(`/email-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
|
||||||
else snackError(res.error || t('emailBot.operationFailed'));
|
else snackError(res.error || t('emailBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
emailTesting = { ...emailTesting, [botId]: false };
|
emailTesting = { ...emailTesting, [botId]: false };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -90,7 +124,7 @@
|
|||||||
title={t('emailBot.title')}
|
title={t('emailBot.title')}
|
||||||
emphasis={t('emailBot.titleEmphasis')}
|
emphasis={t('emailBot.titleEmphasis')}
|
||||||
description={t('emailBot.description')}
|
description={t('emailBot.description')}
|
||||||
crumb="Operators · Bots"
|
crumb={t('crumbs.operatorsBots')}
|
||||||
count={emailBots.length}
|
count={emailBots.length}
|
||||||
countLabel={t('emailBot.countLabel')}
|
countLabel={t('emailBot.countLabel')}
|
||||||
>
|
>
|
||||||
@@ -107,7 +141,7 @@
|
|||||||
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
|
||||||
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
|
<input id="ebot-name" bind:value={emailForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('emailBot.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,16 +190,16 @@
|
|||||||
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each emailBots as bot}
|
{#each emailBots as bot}
|
||||||
<Card hover entityId={bot.id}>
|
<Card hover entityId={bot.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
|
||||||
<p class="font-medium">{bot.name}</p>
|
<p class="font-medium truncate">{bot.name}</p>
|
||||||
</div>
|
</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 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>
|
<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}
|
{#if bot.smtp_use_tls}
|
||||||
@@ -173,7 +207,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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="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="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { MatrixBot } from '$lib/types';
|
import type { MatrixBot } from '$lib/types';
|
||||||
|
|
||||||
let { onreload }: { onreload: () => Promise<void> } = $props();
|
let { onreload }: { onreload: () => Promise<void> } = $props();
|
||||||
@@ -29,14 +30,45 @@
|
|||||||
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
||||||
});
|
});
|
||||||
let matrixForm = $state(defaultMatrixForm());
|
let matrixForm = $state(defaultMatrixForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
|
const DEFAULT_BOT_NAME = 'Matrix Bot';
|
||||||
|
$effect(() => {
|
||||||
|
if (showMatrixForm && !nameManuallyEdited && !editingMatrix) {
|
||||||
|
matrixForm.name = DEFAULT_BOT_NAME;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
function editMatrixBot(bot: MatrixBot) {
|
||||||
matrixForm = {
|
matrixForm = {
|
||||||
name: bot.name, icon: bot.icon || '',
|
name: bot.name, icon: bot.icon || '',
|
||||||
homeserver_url: bot.homeserver_url, access_token: '',
|
homeserver_url: bot.homeserver_url, access_token: '',
|
||||||
display_name: bot.display_name || '',
|
display_name: bot.display_name || '',
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editingMatrix = bot.id; showMatrixForm = true;
|
editingMatrix = bot.id; showMatrixForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +84,8 @@
|
|||||||
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
|
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
|
||||||
snackSuccess(t('snack.matrixBotCreated'));
|
snackSuccess(t('snack.matrixBotCreated'));
|
||||||
}
|
}
|
||||||
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
|
matrixForm = defaultMatrixForm(); nameManuallyEdited = false; showMatrixForm = false; editingMatrix = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { matrixSubmitting = false; }
|
finally { matrixSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +95,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDeleteMatrix = null; }
|
finally { confirmDeleteMatrix = null; }
|
||||||
}
|
}
|
||||||
@@ -79,7 +111,7 @@
|
|||||||
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api(`/matrix-bots/${botId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
||||||
else snackError(res.error || t('matrixBot.operationFailed'));
|
else snackError(res.error || t('matrixBot.operationFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
matrixTesting = { ...matrixTesting, [botId]: false };
|
matrixTesting = { ...matrixTesting, [botId]: false };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -88,7 +120,7 @@
|
|||||||
title={t('matrixBot.title')}
|
title={t('matrixBot.title')}
|
||||||
emphasis={t('matrixBot.titleEmphasis')}
|
emphasis={t('matrixBot.titleEmphasis')}
|
||||||
description={t('matrixBot.description')}
|
description={t('matrixBot.description')}
|
||||||
crumb="Operators · Bots"
|
crumb={t('crumbs.operatorsBots')}
|
||||||
count={matrixBots.length}
|
count={matrixBots.length}
|
||||||
countLabel={t('matrixBot.countLabel')}
|
countLabel={t('matrixBot.countLabel')}
|
||||||
>
|
>
|
||||||
@@ -105,7 +137,7 @@
|
|||||||
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
|
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
|
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
|
||||||
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
|
<input id="mbot-name" bind:value={matrixForm.name} oninput={() => nameManuallyEdited = true} required placeholder={t('matrixBot.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,23 +171,24 @@
|
|||||||
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
|
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each matrixBots as bot}
|
{#each matrixBots as bot}
|
||||||
<Card hover entityId={bot.id}>
|
<Card hover entityId={bot.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
||||||
<p class="font-medium">{bot.name}</p>
|
<p class="font-medium truncate">{bot.name}</p>
|
||||||
</div>
|
</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>
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
|
||||||
{#if bot.display_name}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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="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="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide, fade } from 'svelte/transition';
|
import { slide, fade } from 'svelte/transition';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, errMsg, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { TelegramBot, TelegramChat } from '$lib/types';
|
import type { TelegramBot, TelegramChat } from '$lib/types';
|
||||||
|
|
||||||
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
|
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
|
||||||
@@ -29,10 +30,18 @@
|
|||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let form = $state({ name: '', icon: '', token: '' });
|
let form = $state({ name: '', icon: '', token: '' });
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
|
|
||||||
|
const DEFAULT_BOT_NAME = 'Telegram Bot';
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
form.name = DEFAULT_BOT_NAME;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Per-bot expandable sections
|
// Per-bot expandable sections
|
||||||
let chats = $state<Record<number, TelegramChat[]>>({});
|
let chats = $state<Record<number, TelegramChat[]>>({});
|
||||||
let chatsLoading = $state<Record<number, boolean>>({});
|
let chatsLoading = $state<Record<number, boolean>>({});
|
||||||
@@ -52,8 +61,38 @@
|
|||||||
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
|
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
|
||||||
let botListenerLoading = $state<Record<number, boolean>>({});
|
let botListenerLoading = $state<Record<number, boolean>>({});
|
||||||
|
|
||||||
function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; }
|
function telegramBotTiles(bot: TelegramBot): MetaTile[] {
|
||||||
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; editing = bot.id; showForm = true; }
|
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; }
|
||||||
|
|
||||||
async function saveBot(e: SubmitEvent) {
|
async function saveBot(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = ''; submitting = true;
|
e.preventDefault(); error = ''; submitting = true;
|
||||||
@@ -65,8 +104,8 @@
|
|||||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||||
snackSuccess(t('snack.botRegistered'));
|
snackSuccess(t('snack.botRegistered'));
|
||||||
}
|
}
|
||||||
form = { name: '', icon: '', token: '' }; showForm = false; editing = null; await onreload();
|
form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; showForm = false; editing = null; await onreload();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const m = errMsg(err); error = m; snackError(m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +115,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -108,7 +147,7 @@
|
|||||||
try {
|
try {
|
||||||
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
||||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +156,15 @@
|
|||||||
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
||||||
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
|
chats[botId] = (chats[botId] || []).filter((c) => c.id !== chatDbId);
|
||||||
snackSuccess(t('telegramBot.chatDeleted'));
|
snackSuccess(t('telegramBot.chatDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANG_ITEMS = [
|
// `desc` is the only locale-sensitive field — language *names* are intentionally
|
||||||
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
|
// shown in their own language (English under EN, Русский under RU, …) so users
|
||||||
|
// recognise them regardless of the current UI locale. Only the "Auto" sentinel
|
||||||
|
// for the no-override row is translated.
|
||||||
|
let LANG_ITEMS = $derived([
|
||||||
|
{ value: '', label: '—', icon: 'mdiTranslate', desc: t('common.auto') },
|
||||||
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
|
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
|
||||||
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
|
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
|
||||||
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
|
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
|
||||||
@@ -138,7 +181,7 @@
|
|||||||
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
|
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
|
||||||
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
|
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
|
||||||
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
|
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
|
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +193,7 @@
|
|||||||
c.id === chat.id ? { ...c, language_override: lang } : c
|
c.id === chat.id ? { ...c, language_override: lang } : c
|
||||||
);
|
);
|
||||||
snackSuccess(t('telegramBot.languageUpdated'));
|
snackSuccess(t('telegramBot.languageUpdated'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
||||||
@@ -163,7 +206,7 @@
|
|||||||
chats[botId] = (chats[botId] || []).map(c =>
|
chats[botId] = (chats[botId] || []).map(c =>
|
||||||
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
|
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
|
||||||
);
|
);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadListenerStatus(botId: number) {
|
async function loadListenerStatus(botId: number) {
|
||||||
@@ -193,7 +236,7 @@
|
|||||||
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
|
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncCommands(botId: number) {
|
async function syncCommands(botId: number) {
|
||||||
@@ -202,7 +245,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +258,7 @@
|
|||||||
await loadWebhookStatus(botId);
|
await loadWebhookStatus(botId);
|
||||||
}
|
}
|
||||||
snackSuccess(t('snack.botUpdated'));
|
snackSuccess(t('snack.botUpdated'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +278,7 @@
|
|||||||
} else {
|
} else {
|
||||||
snackError(res.error || t('telegramBot.webhookFailed'));
|
snackError(res.error || t('telegramBot.webhookFailed'));
|
||||||
}
|
}
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +288,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/webhook/unregister`, { method: 'POST' });
|
||||||
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
if (res.success) { snackSuccess(t('telegramBot.webhookUnregistered')); await loadWebhookStatus(botId); }
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
modeChanging = { ...modeChanging, [botId]: false };
|
modeChanging = { ...modeChanging, [botId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +319,7 @@
|
|||||||
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api<ApiResult>(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(res.error || t('telegramBot.saveFailed'));
|
else snackError(res.error || t('telegramBot.saveFailed'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
chatTesting = { ...chatTesting, [key]: false };
|
chatTesting = { ...chatTesting, [key]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +338,7 @@
|
|||||||
title={t('telegramBot.title')}
|
title={t('telegramBot.title')}
|
||||||
emphasis={t('telegramBot.titleEmphasis')}
|
emphasis={t('telegramBot.titleEmphasis')}
|
||||||
description={t('telegramBot.description')}
|
description={t('telegramBot.description')}
|
||||||
crumb="Operators · Bots"
|
crumb={t('crumbs.operatorsBots')}
|
||||||
count={bots.length}
|
count={bots.length}
|
||||||
countLabel={t('telegramBot.countLabel')}
|
countLabel={t('telegramBot.countLabel')}
|
||||||
>
|
>
|
||||||
@@ -312,7 +355,7 @@
|
|||||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
<input id="bot-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('telegramBot.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,18 +378,19 @@
|
|||||||
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each bots as bot}
|
{#each bots as bot}
|
||||||
<Card hover entityId={bot.id}>
|
<Card hover entityId={bot.id}>
|
||||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
<div class="list-row">
|
||||||
<div class="min-w-0">
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||||
<p class="font-medium">{bot.name}</p>
|
<p class="font-medium truncate">{bot.name}</p>
|
||||||
{#if bot.bot_username}
|
{#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}
|
{/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'
|
<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)]'
|
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||||
: (bot.update_mode || 'none') === 'polling'
|
: (bot.update_mode || 'none') === 'polling'
|
||||||
@@ -354,10 +398,11 @@
|
|||||||
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
: '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')}
|
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
|
||||||
</span>
|
</span>
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
|
||||||
</div>
|
</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)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||||
disabled={chatsLoading[bot.id]}
|
disabled={chatsLoading[bot.id]}
|
||||||
@@ -423,6 +468,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={chat.commands_enabled}
|
||||||
|
aria-label={t('telegramBot.commandsToggle')}
|
||||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||||
title={t('telegramBot.commandsToggle')}
|
title={t('telegramBot.commandsToggle')}
|
||||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||||
@@ -470,6 +519,10 @@
|
|||||||
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
|
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={trk.enabled}
|
||||||
|
aria-label={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||||
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||||
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.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';
|
import type { CommandConfig } from '$lib/types';
|
||||||
|
|
||||||
function templateName(id: number | null): string {
|
function templateName(id: number | null): string {
|
||||||
@@ -69,6 +71,14 @@
|
|||||||
command_template_config_id: null as number | null,
|
command_template_config_id: null as number | null,
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const desc = getDescriptor(form.provider_type);
|
||||||
|
form.name = desc ? `${desc.defaultName} Commands` : 'Commands';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let allCapabilities = $derived(capabilitiesCache.items);
|
let allCapabilities = $derived(capabilitiesCache.items);
|
||||||
let providerCommands = $derived<{key: string, icon: string}[]>(
|
let providerCommands = $derived<{key: string, icon: string}[]>(
|
||||||
@@ -95,10 +105,46 @@
|
|||||||
commandTemplateConfigsCache.fetch(),
|
commandTemplateConfigsCache.fetch(),
|
||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first provider type with commands
|
// Auto-select first provider type with commands
|
||||||
@@ -107,6 +153,7 @@
|
|||||||
// Auto-select first matching template for the chosen provider_type
|
// Auto-select first matching template for the chosen provider_type
|
||||||
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
||||||
if (match) form.command_template_config_id = match.id;
|
if (match) form.command_template_config_id = match.id;
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
@@ -142,6 +189,7 @@
|
|||||||
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
|
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
|
||||||
command_template_config_id: cfg.command_template_config_id ?? null,
|
command_template_config_id: cfg.command_template_config_id ?? null,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = cfg.id;
|
editing = cfg.id;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
@@ -165,8 +213,8 @@
|
|||||||
await api('/command-configs', { method: 'POST', body });
|
await api('/command-configs', { method: 'POST', body });
|
||||||
snackSuccess(t('snack.commandConfigSaved'));
|
snackSuccess(t('snack.commandConfigSaved'));
|
||||||
}
|
}
|
||||||
form = defaultForm(); showForm = false; editing = null; await load();
|
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +227,10 @@
|
|||||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.commandConfigDeleted'));
|
snackSuccess(t('snack.commandConfigDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -194,7 +242,7 @@
|
|||||||
title={t('commandConfig.title')}
|
title={t('commandConfig.title')}
|
||||||
emphasis={t('commandConfig.titleEmphasis')}
|
emphasis={t('commandConfig.titleEmphasis')}
|
||||||
description={t('commandConfig.description')}
|
description={t('commandConfig.description')}
|
||||||
crumb="Routing · Commands"
|
crumb={t('crumbs.routingCommands')}
|
||||||
count={configs.length}
|
count={configs.length}
|
||||||
countLabel={t('commandConfig.countLabel')}
|
countLabel={t('commandConfig.countLabel')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -214,7 +262,7 @@
|
|||||||
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
|
<input id="cfg-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandConfig.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,22 +353,20 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each configs as cfg}
|
{#each configs as cfg}
|
||||||
<Card hover entityId={cfg.id}>
|
<Card hover entityId={cfg.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||||
<p class="font-medium">{cfg.name}</p>
|
<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">{cfg.provider_type}</span>
|
<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>
|
||||||
<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>
|
</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)]">
|
<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}
|
· {t('commandConfig.defaultCount')}: {cfg.default_count}
|
||||||
</span>
|
</span>
|
||||||
{#if cfg.command_template_config_id}
|
{#if cfg.command_template_config_id}
|
||||||
@@ -328,7 +374,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
|
import { getDescriptor } from '$lib/providers';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
|
|
||||||
interface CmdTemplateConfig {
|
interface CmdTemplateConfig {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -120,6 +122,14 @@
|
|||||||
slots: {} as Record<string, Record<string, string>>,
|
slots: {} as Record<string, Record<string, string>>,
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const desc = getDescriptor(form.provider_type);
|
||||||
|
form.name = desc ? `${desc.defaultName} Command Templates` : 'Command Templates';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Provider capabilities
|
// Provider capabilities
|
||||||
let allCapabilities = $state<Record<string, any>>({});
|
let allCapabilities = $state<Record<string, any>>({});
|
||||||
@@ -203,8 +213,8 @@
|
|||||||
allCmdTplConfigs = cfgs;
|
allCmdTplConfigs = cfgs;
|
||||||
allCapabilities = caps;
|
allCapabilities = caps;
|
||||||
varsRef = vars;
|
varsRef = vars;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message || t('common.loadError');
|
error = errMsg(err, t('common.loadError'));
|
||||||
snackError(error);
|
snackError(error);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
@@ -253,10 +263,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
||||||
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
activeLocale = primaryLocale;
|
activeLocale = primaryLocale;
|
||||||
@@ -280,6 +329,7 @@
|
|||||||
icon: c.icon || '',
|
icon: c.icon || '',
|
||||||
slots: slotsCopy,
|
slots: slotsCopy,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = c.id;
|
editing = c.id;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
activeLocale = primaryLocale;
|
activeLocale = primaryLocale;
|
||||||
@@ -304,9 +354,10 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.cmdTemplateSaved'));
|
snackSuccess(t('snack.cmdTemplateSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +408,8 @@
|
|||||||
refreshAllPreviews();
|
refreshAllPreviews();
|
||||||
}
|
}
|
||||||
snackSuccess(t('templateConfig.resetApplied'));
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,11 +445,12 @@
|
|||||||
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
}
|
}
|
||||||
@@ -411,7 +463,7 @@
|
|||||||
title={t('cmdTemplateConfig.title')}
|
title={t('cmdTemplateConfig.title')}
|
||||||
emphasis={t('cmdTemplateConfig.titleEmphasis')}
|
emphasis={t('cmdTemplateConfig.titleEmphasis')}
|
||||||
description={t('cmdTemplateConfig.description')}
|
description={t('cmdTemplateConfig.description')}
|
||||||
crumb="Routing · Commands"
|
crumb={t('crumbs.routingCommands')}
|
||||||
count={configs.length}
|
count={configs.length}
|
||||||
countLabel={t('cmdTemplateConfig.countLabel')}
|
countLabel={t('cmdTemplateConfig.countLabel')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -432,7 +484,7 @@
|
|||||||
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
|
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
|
<input id="ct-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,7 +589,7 @@
|
|||||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</CollapsibleSlot>
|
</CollapsibleSlot>
|
||||||
@@ -576,25 +628,25 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each configs as config}
|
{#each configs as config}
|
||||||
<Card hover entityId={config.id}>
|
<Card hover entityId={config.id}>
|
||||||
<div class="flex items-start justify-between">
|
<div class="list-row">
|
||||||
<div class="flex-1">
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||||
<p class="font-medium">{config.name}</p>
|
<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)]">{config.provider_type}</span>
|
<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}
|
{#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}
|
{/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>
|
</div>
|
||||||
{#if config.description}
|
{#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}
|
{/if}
|
||||||
</div>
|
</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="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { providerDefaultIcon } from '$lib/grid-items';
|
import { providerDefaultIcon } from '$lib/grid-items';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||||
|
|
||||||
let allCmdTrackers = $state<any[]>([]);
|
let allCmdTrackers = $state<any[]>([]);
|
||||||
@@ -61,6 +62,14 @@
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const provider = providers.find(p => p.id === form.provider_id);
|
||||||
|
form.name = provider ? `${provider.name} Commands` : 'Commands';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Filter command configs by selected provider's type
|
// Filter command configs by selected provider's type
|
||||||
let filteredConfigs = $derived.by(() => {
|
let filteredConfigs = $derived.by(() => {
|
||||||
@@ -98,7 +107,7 @@
|
|||||||
providersCache.fetch(), commandConfigsCache.fetch(),
|
providersCache.fetch(), commandConfigsCache.fetch(),
|
||||||
telegramBotsCache.fetch(),
|
telegramBotsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +119,7 @@
|
|||||||
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
|
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
|
||||||
if (firstCfg) form.command_config_id = firstCfg.id;
|
if (firstCfg) form.command_config_id = firstCfg.id;
|
||||||
}
|
}
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
@@ -141,6 +151,7 @@
|
|||||||
command_config_id: trk.command_config_id,
|
command_config_id: trk.command_config_id,
|
||||||
enabled: trk.enabled,
|
enabled: trk.enabled,
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = trk.id;
|
editing = trk.id;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
@@ -156,8 +167,8 @@
|
|||||||
await api('/command-trackers', { method: 'POST', body });
|
await api('/command-trackers', { method: 'POST', body });
|
||||||
snackSuccess(t('snack.commandTrackerCreated'));
|
snackSuccess(t('snack.commandTrackerCreated'));
|
||||||
}
|
}
|
||||||
form = defaultForm(); showForm = false; editing = null; await load();
|
form = defaultForm(); nameManuallyEdited = false; showForm = false; editing = null; await load();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +180,7 @@
|
|||||||
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.commandTrackerDeleted'));
|
snackSuccess(t('snack.commandTrackerDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -182,7 +193,7 @@
|
|||||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||||
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
toggling = { ...toggling, [trk.id]: false };
|
toggling = { ...toggling, [trk.id]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +226,7 @@
|
|||||||
snackSuccess(t('snack.listenerAdded'));
|
snackSuccess(t('snack.listenerAdded'));
|
||||||
await loadListeners(trkId);
|
await loadListeners(trkId);
|
||||||
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
addingListener = { ...addingListener, [trkId]: false };
|
addingListener = { ...addingListener, [trkId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +235,7 @@
|
|||||||
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
||||||
snackSuccess(t('snack.listenerRemoved'));
|
snackSuccess(t('snack.listenerRemoved'));
|
||||||
await loadListeners(trkId);
|
await loadListeners(trkId);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-listener album scope editing
|
// Per-listener album scope editing
|
||||||
@@ -253,7 +264,7 @@
|
|||||||
snackSuccess(t('snack.listenerScopeSaved'));
|
snackSuccess(t('snack.listenerScopeSaved'));
|
||||||
await loadListeners(scopeEditor.trkId);
|
await loadListeners(scopeEditor.trkId);
|
||||||
scopeEditor = null;
|
scopeEditor = null;
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerName(id: number): string {
|
function providerName(id: number): string {
|
||||||
@@ -262,13 +273,39 @@
|
|||||||
function configName(id: number): string {
|
function configName(id: number): string {
|
||||||
return commandConfigs.find(c => c.id === id)?.name || '?';
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('commandTracker.title')}
|
title={t('commandTracker.title')}
|
||||||
emphasis={t('commandTracker.titleEmphasis')}
|
emphasis={t('commandTracker.titleEmphasis')}
|
||||||
description={t('commandTracker.description')}
|
description={t('commandTracker.description')}
|
||||||
crumb="Routing · Commands"
|
crumb={t('crumbs.routingCommands')}
|
||||||
count={trackers.length}
|
count={trackers.length}
|
||||||
countLabel={t('dashboard.trackersShort')}
|
countLabel={t('dashboard.trackersShort')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -288,7 +325,7 @@
|
|||||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
|
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
|
<input id="trk-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('commandTracker.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,34 +368,37 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each trackers as trk}
|
{#each trackers as trk}
|
||||||
<Card hover entityId={trk.id}>
|
<Card hover entityId={trk.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||||
<p class="font-medium">{trk.name}</p>
|
<p class="font-medium truncate">{trk.name}</p>
|
||||||
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
|
||||||
<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
|
|
||||||
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||||
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
|
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
|
||||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if trk.listener_count !== undefined}
|
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
|
||||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
|
||||||
</p>
|
{#if trk.listener_count !== undefined}
|
||||||
{/if}
|
<span class="text-xs text-[var(--color-muted-foreground)]">
|
||||||
|
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</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="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]} />
|
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
|
||||||
<button onclick={() => toggleListeners(trk.id)}
|
<button onclick={() => toggleListeners(trk.id)}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
|
{t('commandTracker.listeners')} {expandedTracker === trk.id ? 'в–І' : 'в–ј'}
|
||||||
</button>
|
</button>
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
@@ -433,7 +473,7 @@
|
|||||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||||
{t('backup.selectAll')}
|
{t('backup.selectAll')}
|
||||||
</button>
|
</button>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">В·</span>
|
||||||
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||||
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||||
{t('backup.deselectAll')}
|
{t('backup.deselectAll')}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { login } from '$lib/auth.svelte';
|
import { login } from '$lib/auth.svelte';
|
||||||
import { t, getLocale, setLocale } from '$lib/i18n';
|
import { t, getLocale, setLocale } from '$lib/i18n';
|
||||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||||
if (res.needs_setup) goto('/setup');
|
if (res.needs_setup) goto('/setup');
|
||||||
} catch {
|
} catch {
|
||||||
// The backend is unreachable — surface that distinctly so the user
|
// The backend is unreachable — surface that distinctly so the user
|
||||||
// doesn't blame the login form for a network/backend problem.
|
// doesn't blame the login form for a network/backend problem.
|
||||||
backendDown = true;
|
backendDown = true;
|
||||||
}
|
}
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message || t('auth.loginFailed');
|
error = errMsg(err, t('auth.loginFailed'));
|
||||||
}
|
}
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { api, parseDate } from '$lib/api';
|
import { api, parseDate , errMsg} from '$lib/api';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
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 TrackerForm from './TrackerForm.svelte';
|
||||||
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
||||||
import SharedLinkModal from './SharedLinkModal.svelte';
|
import SharedLinkModal from './SharedLinkModal.svelte';
|
||||||
@@ -70,11 +71,19 @@
|
|||||||
filters: {} as Record<string, any>,
|
filters: {} as Record<string, any>,
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
let selectedProviderType = $derived(
|
let selectedProviderType = $derived(
|
||||||
providers.find(p => p.id === form.provider_id)?.type || ''
|
providers.find(p => p.id === form.provider_id)?.type || ''
|
||||||
);
|
);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const provider = providers.find(p => p.id === form.provider_id);
|
||||||
|
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Linked targets management
|
// Linked targets management
|
||||||
let expandedTracker = $state<number | null>(null);
|
let expandedTracker = $state<number | null>(null);
|
||||||
let addingTarget = $state<Record<number, boolean>>({});
|
let addingTarget = $state<Record<number, boolean>>({});
|
||||||
@@ -157,8 +166,8 @@
|
|||||||
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('common.loadFailed');
|
loadError = errMsg(err, t('common.loadFailed'));
|
||||||
snackError(loadError);
|
snackError(loadError);
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
@@ -210,6 +219,7 @@
|
|||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first provider if any
|
// Auto-select first provider if any
|
||||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +234,7 @@
|
|||||||
filters: trk.filters || {},
|
filters: trk.filters || {},
|
||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = trk.id; showForm = true;
|
editing = trk.id; showForm = true;
|
||||||
if (form.provider_id) {
|
if (form.provider_id) {
|
||||||
await Promise.all([loadCollections(), loadUsers()]);
|
await Promise.all([loadCollections(), loadUsers()]);
|
||||||
@@ -278,7 +289,7 @@
|
|||||||
snackSuccess(t('snack.trackerCreated'));
|
snackSuccess(t('snack.trackerCreated'));
|
||||||
}
|
}
|
||||||
showForm = false; editing = null; linkWarning = null; await load();
|
showForm = false; editing = null; linkWarning = null; await load();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); } finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoCreateLinks() {
|
async function autoCreateLinks() {
|
||||||
@@ -290,8 +301,8 @@
|
|||||||
try {
|
try {
|
||||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
||||||
created++;
|
created++;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
snackError(`Failed to create link for "${album.name}": ${errMsg(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +324,7 @@
|
|||||||
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
} catch (err: unknown) { snackError(errMsg(err)); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
|
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
|
||||||
@@ -324,7 +335,7 @@
|
|||||||
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.trackerDeleted'));
|
snackSuccess(t('snack.trackerDeleted'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +375,54 @@
|
|||||||
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
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)[] {
|
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
||||||
const pt = getProviderType(tracker);
|
const pt = getProviderType(tracker);
|
||||||
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
||||||
@@ -392,7 +451,7 @@
|
|||||||
newLinkTemplateConfigId[trackerId] = 0;
|
newLinkTemplateConfigId[trackerId] = 0;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetLinked'));
|
snackSuccess(t('snack.targetLinked'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
addingTarget = { ...addingTarget, [trackerId]: false };
|
addingTarget = { ...addingTarget, [trackerId]: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +460,7 @@
|
|||||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetUnlinked'));
|
snackSuccess(t('snack.targetUnlinked'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
||||||
@@ -411,7 +470,7 @@
|
|||||||
body: JSON.stringify({ [field]: value }),
|
body: JSON.stringify({ [field]: value }),
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||||
@@ -433,8 +492,8 @@
|
|||||||
} else {
|
} else {
|
||||||
snackSuccess(t('snack.targetTestSent'));
|
snackSuccess(t('snack.targetTestSent'));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
ttTesting = { ...ttTesting, [key]: '' };
|
ttTesting = { ...ttTesting, [key]: '' };
|
||||||
}
|
}
|
||||||
@@ -458,7 +517,7 @@
|
|||||||
title={t('notificationTracker.title')}
|
title={t('notificationTracker.title')}
|
||||||
emphasis={t('notificationTracker.titleEmphasis')}
|
emphasis={t('notificationTracker.titleEmphasis')}
|
||||||
description={t('notificationTracker.description')}
|
description={t('notificationTracker.description')}
|
||||||
crumb="Routing · Notification"
|
crumb={t('crumbs.routingNotification')}
|
||||||
count={notificationTrackers.length}
|
count={notificationTrackers.length}
|
||||||
countLabel={t('dashboard.trackersShort')}
|
countLabel={t('dashboard.trackersShort')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -491,6 +550,7 @@
|
|||||||
onsave={save}
|
onsave={save}
|
||||||
ontoggleCollection={toggleCollection}
|
ontoggleCollection={toggleCollection}
|
||||||
{formatDate}
|
{formatDate}
|
||||||
|
onnameinput={() => nameManuallyEdited = true}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -517,27 +577,30 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each notificationTrackers as tracker (tracker.id)}
|
{#each notificationTrackers as tracker (tracker.id)}
|
||||||
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||||
<Card hover entityId={tracker.id}>
|
<Card hover entityId={tracker.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||||
<p class="font-medium">{tracker.name}</p>
|
<p class="font-medium truncate">{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)]'}">
|
<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')}
|
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||||
</span>
|
</span>
|
||||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<div class="list-row__secondary mt-0.5">
|
||||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||||
</p>
|
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||||
|
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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="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]} />
|
<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)}
|
<button onclick={() => toggleExpand(tracker.id)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { api } from '$lib/api';
|
import { api , errMsg} from '$lib/api';
|
||||||
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
let replacing = $state<Record<string, boolean>>({});
|
let replacing = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expired and password-protected links can't be repaired in place — the
|
* Expired and password-protected links can't be repaired in place — the
|
||||||
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
||||||
* link (which the backend does by POSTing a new one and returning it).
|
* link (which the backend does by POSTing a new one and returning it).
|
||||||
* We surface the action per-row so users don't have to leave the form.
|
* We surface the action per-row so users don't have to leave the form.
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||||
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||||
if (onupdate) onupdate(remaining);
|
if (onupdate) onupdate(remaining);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
replacing = { ...replacing, [album.id]: false };
|
replacing = { ...replacing, [album.id]: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
onsave: (e: SubmitEvent) => void;
|
onsave: (e: SubmitEvent) => void;
|
||||||
ontoggleCollection?: (collectionId: string) => void;
|
ontoggleCollection?: (collectionId: string) => void;
|
||||||
formatDate?: (dateStr: string) => string;
|
formatDate?: (dateStr: string) => string;
|
||||||
|
onnameinput?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -53,12 +55,40 @@
|
|||||||
onsave,
|
onsave,
|
||||||
ontoggleCollection,
|
ontoggleCollection,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
onnameinput,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let descriptor = $derived(getDescriptor(providerType));
|
let descriptor = $derived(getDescriptor(providerType));
|
||||||
let isScheduler = $derived(providerType === 'scheduler');
|
|
||||||
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
|
||||||
let colMeta = $derived(descriptor?.collectionMeta);
|
let colMeta = $derived(descriptor?.collectionMeta);
|
||||||
|
// Providers without a collection (currently just the scheduler) drive the
|
||||||
|
// scheduler-specific form layout. Reading from the descriptor keeps the
|
||||||
|
// branch out of the component and lets a future provider opt in by
|
||||||
|
// declaring `collectionMeta: null`.
|
||||||
|
let isScheduler = $derived(colMeta == null);
|
||||||
|
let isWebhook = $derived(descriptor?.webhookBased ?? false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `{tracking_config_id}` / `{template_config_id}` placeholders in a
|
||||||
|
* descriptor-declared CTA href. When the corresponding form field is unset
|
||||||
|
* (value 0), strip the entire `?edit=...` query so the link still goes to
|
||||||
|
* the list page. Centralising this here avoids per-provider href logic
|
||||||
|
* leaking back into the template.
|
||||||
|
*/
|
||||||
|
function resolveHintHref(href: string): string {
|
||||||
|
const tcId = form.default_tracking_config_id;
|
||||||
|
const tplId = form.default_template_config_id;
|
||||||
|
if (href.includes('{tracking_config_id}')) {
|
||||||
|
return tcId
|
||||||
|
? href.replace('{tracking_config_id}', String(tcId))
|
||||||
|
: href.split('?')[0];
|
||||||
|
}
|
||||||
|
if (href.includes('{template_config_id}')) {
|
||||||
|
return tplId
|
||||||
|
? href.replace('{template_config_id}', String(tplId))
|
||||||
|
: href.split('?')[0];
|
||||||
|
}
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom variable management for scheduler
|
// Custom variable management for scheduler
|
||||||
function addVariable() {
|
function addVariable() {
|
||||||
@@ -95,7 +125,7 @@
|
|||||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="trk-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -121,14 +151,24 @@
|
|||||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||||
{#each descriptor.userFilters as uf (uf.key)}
|
{#each descriptor.userFilters as uf (uf.key)}
|
||||||
|
{@const filterKey = uf.filterKey ?? uf.key}
|
||||||
<div>
|
<div>
|
||||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||||
<MultiEntitySelect
|
{#if uf.inputMode === 'tags'}
|
||||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
<TagInput
|
||||||
values={form.filters[uf.key] || []}
|
values={form.filters[filterKey] || []}
|
||||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||||
placeholder={t(uf.placeholder)}
|
placeholder={t(uf.placeholder)}
|
||||||
/>
|
icon={uf.icon}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<MultiEntitySelect
|
||||||
|
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||||
|
values={form.filters[filterKey] || []}
|
||||||
|
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||||
|
placeholder={t(uf.placeholder)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -218,20 +258,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||||
live on the tracking config, not on the tracker itself. Surface this
|
live on the tracking config, not on the tracker itself. The hint
|
||||||
here so users don't have to stumble onto the feature by reading docs. -->
|
content (message + CTAs) is declared on the provider descriptor so
|
||||||
{#if providerType === 'immich'}
|
each provider can surface its own discoverability links without
|
||||||
|
embedding `if (type === 'xyz')` here. -->
|
||||||
|
{#if descriptor?.featureDiscoveryHint}
|
||||||
|
{@const hint = descriptor.featureDiscoveryHint}
|
||||||
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||||
<div class="flex-1 text-xs">
|
<div class="flex-1 text-xs">
|
||||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
<p style="color: var(--color-muted-foreground);">{t(hint.messageKey)}</p>
|
||||||
<a href={form.default_tracking_config_id
|
{#if hint.ctas && hint.ctas.length > 0}
|
||||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||||
: '/tracking-configs'}
|
{#each hint.ctas as cta}
|
||||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
<a href={resolveHintHref(cta.href)}
|
||||||
<MdiIcon name="mdiArrowRight" size={12} />
|
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||||
{t('notificationTracker.openTrackingConfig')}
|
<MdiIcon name={cta.icon ?? 'mdiArrowRight'} size={12} />
|
||||||
</a>
|
{t(cta.labelKey)}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache } from '$lib/stores/caches.svelte';
|
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -21,10 +21,11 @@
|
|||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
|
||||||
import type { ServiceProvider } from '$lib/types';
|
import type { ServiceProvider } from '$lib/types';
|
||||||
|
|
||||||
@@ -45,6 +46,91 @@
|
|||||||
let confirmDelete = $state<ServiceProvider | null>(null);
|
let confirmDelete = $state<ServiceProvider | null>(null);
|
||||||
|
|
||||||
let descriptor = $derived(getDescriptor(form.type));
|
let descriptor = $derived(getDescriptor(form.type));
|
||||||
|
let externalUrl = $derived(externalUrlCache.value);
|
||||||
|
|
||||||
|
function buildWebhookUrl(pattern: string, token: string): string {
|
||||||
|
const path = pattern.replace('{token}', token ?? '');
|
||||||
|
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();
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = url;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
snackInfo(`${t('snack.copied')}: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-update name when provider type changes (unless user manually edited)
|
// Auto-update name when provider type changes (unless user manually edited)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -76,14 +162,15 @@
|
|||||||
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
|
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
|
||||||
});
|
});
|
||||||
load();
|
load();
|
||||||
|
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
|
||||||
});
|
});
|
||||||
onDestroy(() => topbarAction.clear());
|
onDestroy(() => topbarAction.clear());
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
await providersCache.fetch(true);
|
await providersCache.fetch(true);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('providers.loadError');
|
loadError = errMsg(err, t('providers.loadError'));
|
||||||
} finally { loaded = true; highlightFromUrl(); }
|
} finally { loaded = true; highlightFromUrl(); }
|
||||||
// Ping all providers in background (use unfiltered list)
|
// Ping all providers in background (use unfiltered list)
|
||||||
for (const p of allProviders) {
|
for (const p of allProviders) {
|
||||||
@@ -150,7 +237,7 @@
|
|||||||
}
|
}
|
||||||
showForm = false; editing = null; providersCache.invalidate(); await load();
|
showForm = false; editing = null; providersCache.invalidate(); await load();
|
||||||
snackSuccess(t('snack.providerSaved'));
|
snackSuccess(t('snack.providerSaved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +248,10 @@
|
|||||||
const id = confirmDelete.id;
|
const id = confirmDelete.id;
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -173,7 +260,7 @@
|
|||||||
title={t('providers.title')}
|
title={t('providers.title')}
|
||||||
emphasis={t('providers.titleEmphasis')}
|
emphasis={t('providers.titleEmphasis')}
|
||||||
description={t('providers.description')}
|
description={t('providers.description')}
|
||||||
crumb="Service · Connections"
|
crumb={t('crumbs.serviceConnections')}
|
||||||
count={providers.length}
|
count={providers.length}
|
||||||
countLabel={t('dashboard.providersShort')}
|
countLabel={t('dashboard.providersShort')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -197,7 +284,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div in:slide={{ duration: 200 }}>
|
<div in:slide={{ duration: 200 }} class="list-stack">
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
<form onsubmit={save} class="space-y-3">
|
<form onsubmit={save} class="space-y-3">
|
||||||
@@ -234,6 +321,11 @@
|
|||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||||
min={field.min} max={field.max}
|
min={field.min} max={field.max}
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
{:else if field.type === 'toggle'}
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||||
required={field.required === true || (field.required === 'create-only' && !editing)}
|
required={field.required === true || (field.required === 'create-only' && !editing)}
|
||||||
@@ -246,9 +338,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if descriptor?.webhookUrlPattern && editing}
|
{#if descriptor?.webhookUrlPattern && editing}
|
||||||
|
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
|
||||||
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
<div class="bg-[var(--color-muted)] rounded-md p-3">
|
||||||
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
|
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
|
||||||
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
|
<button type="button"
|
||||||
|
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
|
||||||
|
title={t('providers.webhookUrlCopyTitle')}
|
||||||
|
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
|
||||||
|
<code class="bg-transparent">{editingWebhookUrl}</code>
|
||||||
|
</button>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -261,9 +359,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !showForm && allProviders.length > 0}
|
{#if !showForm && allProviders.length > 0}
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="list-stack mb-3">
|
||||||
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
<div class="flex items-center gap-2">
|
||||||
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -276,30 +376,43 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each providers as provider}
|
{#each providers as provider}
|
||||||
{@const provDesc = getDescriptor(provider.type)}
|
{@const provDesc = getDescriptor(provider.type)}
|
||||||
<Card hover entityId={provider.id}>
|
<Card hover entityId={provider.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div class="flex items-center gap-3">
|
<div class="list-row__identity">
|
||||||
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
<div class="flex items-center gap-3">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
|
||||||
<div>
|
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="min-w-0">
|
||||||
<p class="font-medium">{provider.name}</p>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
|
<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>
|
</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}
|
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</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="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,6 +107,11 @@
|
|||||||
{:else if field.type === 'number'}
|
{:else if field.type === 'number'}
|
||||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
{:else if field.type === 'toggle'}
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||||
required={field.required === true || field.required === 'create-only'}
|
required={field.required === true || field.required === 'create-only'}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
||||||
import Hint from '$lib/components/Hint.svelte';
|
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
|
||||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||||
|
|
||||||
|
import SettingsHero from './SettingsHero.svelte';
|
||||||
|
import IdentityCassette from './IdentityCassette.svelte';
|
||||||
|
import TelegramCassette from './TelegramCassette.svelte';
|
||||||
|
import ReleaseCassette from './ReleaseCassette.svelte';
|
||||||
|
import CacheLedger from './CacheLedger.svelte';
|
||||||
|
import LoggingCassette from './LoggingCassette.svelte';
|
||||||
|
import SaveBar from './SaveBar.svelte';
|
||||||
|
|
||||||
interface CacheBucketStats {
|
interface CacheBucketStats {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -25,12 +27,24 @@
|
|||||||
asset: CacheBucketStats;
|
asset: CacheBucketStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = $state(false);
|
interface Settings {
|
||||||
let saving = $state(false);
|
external_url: string;
|
||||||
let clearingCache = $state(false);
|
telegram_webhook_secret: string;
|
||||||
let confirmClearCache = $state(false);
|
telegram_cache_ttl_hours: string;
|
||||||
let error = $state('');
|
telegram_asset_cache_max_entries: string;
|
||||||
let settings = $state({
|
supported_locales: string;
|
||||||
|
timezone: string;
|
||||||
|
log_level: string;
|
||||||
|
log_format: string;
|
||||||
|
log_levels: string;
|
||||||
|
release_provider_kind: string;
|
||||||
|
release_provider_url: string;
|
||||||
|
release_provider_repo: string;
|
||||||
|
release_include_prereleases: string;
|
||||||
|
release_check_interval_hours: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Settings = {
|
||||||
external_url: '',
|
external_url: '',
|
||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '720',
|
telegram_cache_ttl_hours: '720',
|
||||||
@@ -40,10 +54,38 @@
|
|||||||
log_level: 'INFO',
|
log_level: 'INFO',
|
||||||
log_format: 'text',
|
log_format: 'text',
|
||||||
log_levels: '',
|
log_levels: '',
|
||||||
});
|
release_provider_kind: 'gitea',
|
||||||
|
release_provider_url: 'https://git.dolgolyov-family.by',
|
||||||
|
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
|
||||||
|
release_include_prereleases: '0',
|
||||||
|
release_check_interval_hours: '12',
|
||||||
|
};
|
||||||
|
|
||||||
|
let loaded = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let clearingCache = $state(false);
|
||||||
|
let confirmClearCache = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let settings = $state<Settings>({ ...EMPTY });
|
||||||
|
// Snapshot of the last server-known state, used for dirty tracking.
|
||||||
|
let baseline = $state<Settings>({ ...EMPTY });
|
||||||
let cacheStats = $state<CacheStats | null>(null);
|
let cacheStats = $state<CacheStats | null>(null);
|
||||||
|
|
||||||
async function loadCacheStats() {
|
// --- Dirty tracking -----------------------------------------------------
|
||||||
|
|
||||||
|
const dirtyKeys = $derived.by<Array<keyof Settings>>(() => {
|
||||||
|
const out: Array<keyof Settings> = [];
|
||||||
|
for (const key of Object.keys(settings) as Array<keyof Settings>) {
|
||||||
|
if (settings[key] !== baseline[key]) out.push(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
const dirty = $derived(dirtyKeys.length > 0);
|
||||||
|
|
||||||
|
// --- Data loading -------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadCacheStats(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||||
} catch { cacheStats = null; }
|
} catch { cacheStats = null; }
|
||||||
@@ -51,211 +93,151 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings');
|
const fetched = await api<Settings>('/settings');
|
||||||
|
settings = { ...EMPTY, ...fetched };
|
||||||
|
baseline = { ...settings };
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
// Warm the release status so the cassette renders the strip on first paint.
|
||||||
finally { loaded = true; }
|
await releaseStatusCache.fetch();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to load settings';
|
||||||
|
error = msg;
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
// --- Actions ------------------------------------------------------------
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let i = 0;
|
|
||||||
let v = bytes;
|
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
||||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTs(iso: string | null): string {
|
async function save(): Promise<void> {
|
||||||
if (!iso) return '—';
|
saving = true;
|
||||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
error = '';
|
||||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
saving = true; error = '';
|
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
const next = await api<Settings>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
settings = { ...EMPTY, ...next };
|
||||||
|
baseline = { ...settings };
|
||||||
|
externalUrlCache.invalidate();
|
||||||
|
// Release config may have changed → drop the cached status and
|
||||||
|
// refetch so the sidebar badge + cassette strip reflect the
|
||||||
|
// freshly-rescheduled probe without waiting for the next route
|
||||||
|
// change to trigger another read.
|
||||||
|
releaseStatusCache.invalidate();
|
||||||
|
void releaseStatusCache.fetch(true);
|
||||||
snackSuccess(t('settings.saved'));
|
snackSuccess(t('settings.saved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) {
|
||||||
saving = false;
|
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||||
|
error = msg;
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearTelegramCache() {
|
function discard(): void {
|
||||||
|
settings = { ...baseline };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTelegramCache(): Promise<void> {
|
||||||
confirmClearCache = false;
|
confirmClearCache = false;
|
||||||
clearingCache = true;
|
clearingCache = true;
|
||||||
try {
|
try {
|
||||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||||
snackSuccess(t('settings.clearCacheDone'));
|
snackSuccess(t('settings.clearCacheDone'));
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) {
|
||||||
clearingCache = false;
|
const msg = err instanceof Error ? err.message : 'Clear cache failed';
|
||||||
|
snackError(msg);
|
||||||
|
} finally {
|
||||||
|
clearingCache = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheMaxEntriesNum = $derived(
|
||||||
|
Math.max(0, Number(settings.telegram_asset_cache_max_entries || '0')),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<SettingsHero {settings} />
|
||||||
title={t('settings.title')}
|
|
||||||
emphasis={t('settings.titleEmphasis')}
|
|
||||||
description={t('settings.description')}
|
|
||||||
crumb="System · Configuration"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- General section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiCog" size={18} />
|
|
||||||
{t('settings.general')}
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.externalUrl')}<Hint text={t('settings.externalUrlHint')} /></label>
|
|
||||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
|
||||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
|
||||||
<TimezoneSelector bind:value={settings.timezone} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Telegram section -->
|
<div class="settings-page stagger-children">
|
||||||
<Card>
|
<IdentityCassette
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
bind:externalUrl={settings.external_url}
|
||||||
<MdiIcon name="mdiSend" size={18} />
|
bind:timezone={settings.timezone}
|
||||||
{t('settings.telegram')}
|
bind:supportedLocales={settings.supported_locales}
|
||||||
</h3>
|
/>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
|
||||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
|
||||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
|
||||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
|
||||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
|
||||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
|
||||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
|
||||||
{#each [
|
|
||||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
|
||||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
|
||||||
] as bucket}
|
|
||||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
|
||||||
<span class="font-medium">{bucket.label}</span>
|
|
||||||
{#if bucket.data && bucket.data.count > 0}
|
|
||||||
<span>
|
|
||||||
<span class="font-mono">{bucket.data.count}</span>
|
|
||||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
|
||||||
{#if bucket.data.total_size_bytes > 0}
|
|
||||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
|
||||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
|
||||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
|
||||||
{#if bucket.data.oldest}
|
|
||||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
|
||||||
{/if}
|
|
||||||
{#if bucket.data.newest}
|
|
||||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
|
||||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
|
||||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
|
||||||
</button>
|
|
||||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Locales section -->
|
<div class="telegram-deck">
|
||||||
<Card>
|
<TelegramCassette
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
bind:webhookSecret={settings.telegram_webhook_secret}
|
||||||
<MdiIcon name="mdiTranslate" size={18} />
|
bind:cacheTtlHours={settings.telegram_cache_ttl_hours}
|
||||||
{t('settings.locales')}
|
bind:cacheMaxEntries={settings.telegram_asset_cache_max_entries}
|
||||||
</h3>
|
/>
|
||||||
<div class="space-y-3">
|
<CacheLedger
|
||||||
<div>
|
stats={cacheStats}
|
||||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
clearing={clearingCache}
|
||||||
<LocaleSelector bind:value={settings.supported_locales} />
|
maxEntries={cacheMaxEntriesNum}
|
||||||
</div>
|
onRefresh={loadCacheStats}
|
||||||
</div>
|
onClear={() => (confirmClearCache = true)}
|
||||||
</Card>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Logging section -->
|
<ReleaseCassette
|
||||||
<Card>
|
bind:providerKind={settings.release_provider_kind}
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
bind:providerUrl={settings.release_provider_url}
|
||||||
<MdiIcon name="mdiTextBoxOutline" size={18} />
|
bind:providerRepo={settings.release_provider_repo}
|
||||||
{t('settings.logging')}
|
bind:includePrereleases={settings.release_include_prereleases}
|
||||||
</h3>
|
bind:checkIntervalHours={settings.release_check_interval_hours}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
/>
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
|
|
||||||
<select bind:value={settings.log_level}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="DEBUG">DEBUG</option>
|
|
||||||
<option value="INFO">INFO</option>
|
|
||||||
<option value="WARNING">WARNING</option>
|
|
||||||
<option value="ERROR">ERROR</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
|
|
||||||
<select bind:value={settings.log_format}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="text">text</option>
|
|
||||||
<option value="json">json</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-2">
|
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
|
|
||||||
<input bind:value={settings.log_levels}
|
|
||||||
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button onclick={save} disabled={saving}>
|
<LoggingCassette
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
bind:logLevel={settings.log_level}
|
||||||
</Button>
|
bind:logFormat={settings.log_format}
|
||||||
|
bind:logLevels={settings.log_levels}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal open={confirmClearCache}
|
<SaveBar
|
||||||
title={t('settings.clearCacheConfirmTitle')}
|
{dirty}
|
||||||
message={t('settings.clearCacheConfirm')}
|
{saving}
|
||||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
changedCount={dirtyKeys.length}
|
||||||
confirmIcon="mdiDeleteSweep"
|
onSave={save}
|
||||||
onconfirm={clearTelegramCache}
|
onDiscard={discard}
|
||||||
oncancel={() => confirmClearCache = false} />
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmClearCache}
|
||||||
|
title={t('settings.clearCacheConfirmTitle')}
|
||||||
|
message={t('settings.clearCacheConfirm')}
|
||||||
|
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||||
|
confirmIcon="mdiDeleteSweep"
|
||||||
|
onconfirm={clearTelegramCache}
|
||||||
|
oncancel={() => (confirmClearCache = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-deck {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.telegram-deck { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||||
|
|
||||||
|
interface CacheBucketStats {
|
||||||
|
count: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
oldest: string | null;
|
||||||
|
newest: string | null;
|
||||||
|
}
|
||||||
|
interface CacheStats {
|
||||||
|
url: CacheBucketStats;
|
||||||
|
asset: CacheBucketStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: CacheStats | null;
|
||||||
|
clearing: boolean;
|
||||||
|
maxEntries: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { stats, clearing, maxEntries, onRefresh, onClear }: Props = $props();
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = bytes;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(iso: string | null): Date | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageTone(iso: string | null): Tone {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return 'mint';
|
||||||
|
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
if (hours < 48) return 'mint';
|
||||||
|
if (hours < 24 * 7) return 'sky';
|
||||||
|
if (hours < 24 * 30) return 'citrus';
|
||||||
|
return 'coral';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BucketRow {
|
||||||
|
key: 'url' | 'asset';
|
||||||
|
labelKey: string;
|
||||||
|
icon: string;
|
||||||
|
data: CacheBucketStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = $derived<BucketRow[]>([
|
||||||
|
{ key: 'url', labelKey: 'settings.cacheStatsUrl', icon: 'mdiLinkVariant', data: stats?.url ?? null },
|
||||||
|
{ key: 'asset', labelKey: 'settings.cacheStatsAsset', icon: 'mdiImageMultipleOutline', data: stats?.asset ?? null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = $derived(
|
||||||
|
(stats?.url.count ?? 0) + (stats?.asset.count ?? 0),
|
||||||
|
);
|
||||||
|
const totalBytes = $derived(
|
||||||
|
(stats?.url.total_size_bytes ?? 0) + (stats?.asset.total_size_bytes ?? 0),
|
||||||
|
);
|
||||||
|
const fillPct = $derived.by(() => {
|
||||||
|
const max = Math.max(1, maxEntries);
|
||||||
|
const each = totalCount / 2; // two buckets share the cap conceptually; use whichever is fuller
|
||||||
|
const top = Math.max(stats?.url.count ?? 0, stats?.asset.count ?? 0);
|
||||||
|
void each; // explicit ack we considered both
|
||||||
|
return Math.min(100, Math.round((top / max) * 100));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="ledger glass">
|
||||||
|
<header class="ledger-head">
|
||||||
|
<div class="ledger-summary">
|
||||||
|
<div class="ledger-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseClockOutline" size={12} />
|
||||||
|
<span>{t('settings.cacheStats')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-numbers">
|
||||||
|
<span class="ledger-count font-mono">{totalCount.toLocaleString()}</span>
|
||||||
|
<span class="ledger-count-label">{t('settings.cacheStatsEntries')}</span>
|
||||||
|
{#if totalBytes > 0}
|
||||||
|
<span class="ledger-sep">·</span>
|
||||||
|
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
|
||||||
|
<Hint text={t('settings.cacheStatsHint')} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={onRefresh}
|
||||||
|
aria-label={t('common.refresh', 'Refresh')}
|
||||||
|
title={t('common.refresh', 'Refresh')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiRefresh" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Capacity meter (peak bucket vs configured cap) -->
|
||||||
|
{#if maxEntries > 0}
|
||||||
|
<div class="meter" aria-label={t('settings.cacheCapacity')}>
|
||||||
|
<div class="meter-track">
|
||||||
|
<div class="meter-fill" style="width: {fillPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="meter-text font-mono">
|
||||||
|
{fillPct}% · {t('settings.cacheCapacityCap').replace('{n}', maxEntries.toLocaleString())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Bucket rows -->
|
||||||
|
<ol class="ledger-list">
|
||||||
|
{#each buckets as bucket (bucket.key)}
|
||||||
|
{@const data = bucket.data}
|
||||||
|
{@const empty = !data || data.count === 0}
|
||||||
|
{@const tone = empty ? 'mint' : ageTone(data?.oldest ?? null)}
|
||||||
|
<li class="row" data-tone={tone} class:row-empty={empty}>
|
||||||
|
<span class="row-edge" aria-hidden="true"></span>
|
||||||
|
<span class="row-icon" aria-hidden="true">
|
||||||
|
<MdiIcon name={bucket.icon} size={16} />
|
||||||
|
</span>
|
||||||
|
<div class="row-text">
|
||||||
|
<span class="row-name">{t(bucket.labelKey)}</span>
|
||||||
|
{#if empty}
|
||||||
|
<span class="row-meta">{t('settings.cacheStatsEmpty')}</span>
|
||||||
|
{:else if data}
|
||||||
|
<span class="row-meta">
|
||||||
|
<span>
|
||||||
|
<span class="font-mono">{data.count.toLocaleString()}</span>
|
||||||
|
{t('settings.cacheStatsEntries')}
|
||||||
|
</span>
|
||||||
|
{#if data.total_size_bytes > 0}
|
||||||
|
<span class="row-sep">·</span>
|
||||||
|
<span class="font-mono">{formatBytes(data.total_size_bytes)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if data.oldest}
|
||||||
|
<span class="row-sep">·</span>
|
||||||
|
<span>{t('settings.cacheStatsOldest')} {relativeTime(data.oldest)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="row-dot" aria-hidden="true"></span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<footer class="ledger-foot">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onClear} disabled={clearing || totalCount === 0}>
|
||||||
|
{#if clearing}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiDeleteSweep" size={14} />
|
||||||
|
{/if}
|
||||||
|
{clearing ? t('common.loading') : t('settings.clearCache')}
|
||||||
|
</Button>
|
||||||
|
<span class="foot-hint">{t('settings.clearCacheHint')}</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ledger {
|
||||||
|
padding: 1.4rem 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.ledger-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-summary { min-width: 0; }
|
||||||
|
.ledger-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.ledger-numbers {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-count {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ledger-count-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||||
|
.ledger-bytes {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Capacity meter --- */
|
||||||
|
.meter {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.meter-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--color-mint), var(--color-sky));
|
||||||
|
border-radius: inherit;
|
||||||
|
transition: width 0.4s cubic-bezier(.2,.7,.2,1);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 40%, transparent);
|
||||||
|
}
|
||||||
|
.meter-text {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bucket rows --- */
|
||||||
|
.ledger-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.7rem 0.95rem 0.7rem 1.1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.row.row-empty { opacity: 0.78; }
|
||||||
|
|
||||||
|
.row-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-icon { color: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-icon { color: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-icon { color: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-icon { color: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.row-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.row-sep { opacity: 0.45; }
|
||||||
|
|
||||||
|
.row-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||||
|
.row[data-tone="sky"] .row-dot { background: var(--color-sky); box-shadow: 0 0 8px color-mix(in srgb, var(--color-sky) 50%, transparent); }
|
||||||
|
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); box-shadow: 0 0 8px color-mix(in srgb, var(--color-citrus) 50%, transparent); }
|
||||||
|
.row[data-tone="coral"] .row-dot { background: var(--color-coral); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral) 50%, transparent); }
|
||||||
|
|
||||||
|
/* --- Footer --- */
|
||||||
|
.ledger-foot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.foot-hint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.row, .meter-fill { transition: none !important; }
|
||||||
|
.row:hover { transform: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||||
|
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||||
|
import { snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
externalUrl: string;
|
||||||
|
timezone: string;
|
||||||
|
supportedLocales: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
externalUrl = $bindable(),
|
||||||
|
timezone = $bindable(),
|
||||||
|
supportedLocales = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function copyUrl(): void {
|
||||||
|
if (!externalUrl) return;
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(externalUrl);
|
||||||
|
copied = true;
|
||||||
|
snackSuccess(t('settings.urlCopied'));
|
||||||
|
if (copyTimer) clearTimeout(copyTimer);
|
||||||
|
copyTimer = setTimeout(() => { copied = false; }, 1600);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReachable(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
try { new URL(url); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlValid = $derived(isReachable(externalUrl));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="identity glass">
|
||||||
|
<header class="identity-head">
|
||||||
|
<div class="identity-eyebrow">
|
||||||
|
<MdiIcon name="mdiAccountNetworkOutline" size={12} />
|
||||||
|
<span>{t('settings.identity')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="identity-title">{t('settings.identityHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="identity-body">
|
||||||
|
<!-- External URL row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">01</span>
|
||||||
|
<label for="settings-external-url" class="row-name">
|
||||||
|
{t('settings.externalUrl')}
|
||||||
|
<Hint text={t('settings.externalUrlHint')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<div class="url-field" class:url-field-valid={urlValid && !!externalUrl}>
|
||||||
|
<span class="url-leading" aria-hidden="true">
|
||||||
|
<MdiIcon name={urlValid ? 'mdiEarth' : 'mdiEarthOff'} size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="settings-external-url"
|
||||||
|
bind:value={externalUrl}
|
||||||
|
placeholder="https://notify.example.com"
|
||||||
|
class="url-input"
|
||||||
|
type="url"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
{#if externalUrl}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="url-action"
|
||||||
|
onclick={copyUrl}
|
||||||
|
aria-label={t('settings.copy')}
|
||||||
|
title={t('settings.copy')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={copied ? 'mdiCheck' : 'mdiContentCopy'} size={13} />
|
||||||
|
</button>
|
||||||
|
{#if urlValid}
|
||||||
|
<a
|
||||||
|
href={externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="url-action"
|
||||||
|
aria-label={t('settings.openExternal')}
|
||||||
|
title={t('settings.openExternal')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timezone row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">02</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.timezone')}
|
||||||
|
<Hint text={t('settings.timezoneHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<TimezoneSelector bind:value={timezone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locales row -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">03</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.supportedLocales')}
|
||||||
|
<Hint text={t('settings.supportedLocalesHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<LocaleSelector bind:value={supportedLocales} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.identity-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.identity-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 42ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr;
|
||||||
|
gap: 1.4rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||||
|
.row:last-child { padding-bottom: 0.1rem; }
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.row-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- URL field with leading icon and trailing actions --- */
|
||||||
|
.url-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 34rem;
|
||||||
|
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
.url-field:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow);
|
||||||
|
}
|
||||||
|
.url-field-valid {
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
.url-leading {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.url-field-valid .url-leading { color: var(--color-mint); }
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.url-input::placeholder { color: var(--color-muted-foreground); }
|
||||||
|
.url-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.url-action:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.95rem 0;
|
||||||
|
}
|
||||||
|
.row-label { padding-top: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.url-field, .url-action { transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
|
import { logLevelItems, logFormatItems } from '$lib/grid-items';
|
||||||
|
|
||||||
|
type Level = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
|
||||||
|
|
||||||
|
interface Override {
|
||||||
|
module: string;
|
||||||
|
level: Level;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logLevel: string;
|
||||||
|
logFormat: string;
|
||||||
|
logLevels: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
logLevel = $bindable(),
|
||||||
|
logFormat = $bindable(),
|
||||||
|
logLevels = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LEVELS: Level[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
|
||||||
|
const LEVEL_TONE: Record<Level, string> = {
|
||||||
|
DEBUG: 'sky',
|
||||||
|
INFO: 'mint',
|
||||||
|
WARNING: 'citrus',
|
||||||
|
ERROR: 'coral',
|
||||||
|
};
|
||||||
|
|
||||||
|
let rawMode = $state(false);
|
||||||
|
|
||||||
|
// Parse the comma-separated `module=LEVEL,...` string into structured rows.
|
||||||
|
function parse(csv: string): Override[] {
|
||||||
|
if (!csv) return [];
|
||||||
|
const out: Override[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of csv.split(',')) {
|
||||||
|
const piece = raw.trim();
|
||||||
|
if (!piece) continue;
|
||||||
|
const eq = piece.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const module = piece.slice(0, eq).trim();
|
||||||
|
const lvlRaw = piece.slice(eq + 1).trim().toUpperCase();
|
||||||
|
if (!module || seen.has(module)) continue;
|
||||||
|
const level = (LEVELS.includes(lvlRaw as Level) ? lvlRaw : 'INFO') as Level;
|
||||||
|
seen.add(module);
|
||||||
|
out.push({ module, level });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(rows: Override[]): string {
|
||||||
|
return rows
|
||||||
|
.filter(r => r.module.trim().length > 0)
|
||||||
|
.map(r => `${r.module.trim()}=${r.level}`)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = $state<Override[]>(parse(logLevels));
|
||||||
|
let lastEmitted = $state(logLevels);
|
||||||
|
|
||||||
|
// Re-parse when the upstream string changes from outside (e.g. fetch / reset).
|
||||||
|
$effect(() => {
|
||||||
|
if (logLevels !== lastEmitted) {
|
||||||
|
rows = parse(logLevels);
|
||||||
|
lastEmitted = logLevels;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit(next: Override[]): void {
|
||||||
|
rows = next;
|
||||||
|
const serialized = serialize(next);
|
||||||
|
lastEmitted = serialized;
|
||||||
|
logLevels = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(): void {
|
||||||
|
commit([...rows, { module: '', level: 'INFO' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(i: number): void {
|
||||||
|
commit(rows.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModule(i: number, value: string): void {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, module: value } : r));
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLevel(i: number, level: Level): void {
|
||||||
|
const next = rows.map((r, idx) => (idx === i ? { ...r, level } : r));
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewLine = $derived.by(() => {
|
||||||
|
const root = (logLevel || 'INFO').toUpperCase();
|
||||||
|
if (rows.length === 0) return `root=${root}`;
|
||||||
|
return `root=${root}, ${rows.map(r => `${r.module || '?'}=${r.level}`).join(', ')}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="logging glass">
|
||||||
|
<header class="log-head">
|
||||||
|
<div class="log-eyebrow">
|
||||||
|
<MdiIcon name="mdiTextBoxOutline" size={12} />
|
||||||
|
<span>{t('settings.logging')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="log-title">{t('settings.loggingHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Level + format -->
|
||||||
|
<div class="log-row">
|
||||||
|
<div class="log-cell">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logLevel')}
|
||||||
|
<Hint text={t('settings.logLevelHint')} />
|
||||||
|
</span>
|
||||||
|
<IconGridSelect items={logLevelItems()} bind:value={logLevel} columns={2} />
|
||||||
|
</div>
|
||||||
|
<div class="log-cell">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logFormat')}
|
||||||
|
<Hint text={t('settings.logFormatHint')} />
|
||||||
|
</span>
|
||||||
|
<IconGridSelect items={logFormatItems()} bind:value={logFormat} columns={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-module overrides -->
|
||||||
|
<div class="overrides">
|
||||||
|
<div class="overrides-head">
|
||||||
|
<span class="log-label">
|
||||||
|
{t('settings.logLevels')}
|
||||||
|
<Hint text={t('settings.logLevelsHint')} />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mode-toggle"
|
||||||
|
onclick={() => (rawMode = !rawMode)}
|
||||||
|
title={rawMode ? t('settings.editAsChips') : t('settings.editAsText')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={rawMode ? 'mdiViewList' : 'mdiCodeBraces'} size={12} />
|
||||||
|
<span>{rawMode ? t('settings.editAsChips') : t('settings.editAsText')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rawMode}
|
||||||
|
<input
|
||||||
|
bind:value={logLevels}
|
||||||
|
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||||
|
class="raw-input"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="chip-stack">
|
||||||
|
{#each rows as row, i (i)}
|
||||||
|
{@const tone = LEVEL_TONE[row.level]}
|
||||||
|
<div class="chip" data-tone={tone}>
|
||||||
|
<span class="chip-edge" aria-hidden="true"></span>
|
||||||
|
<input
|
||||||
|
value={row.module}
|
||||||
|
oninput={(e) => updateModule(i, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
placeholder={t('settings.logModulePlaceholder')}
|
||||||
|
class="chip-input"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<span class="chip-sep" aria-hidden="true">=</span>
|
||||||
|
<select
|
||||||
|
value={row.level}
|
||||||
|
onchange={(e) => updateLevel(i, (e.currentTarget as HTMLSelectElement).value as Level)}
|
||||||
|
class="chip-level"
|
||||||
|
aria-label={t('settings.logLevel')}
|
||||||
|
>
|
||||||
|
{#each LEVELS as lvl}
|
||||||
|
<option value={lvl}>{lvl}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip-remove"
|
||||||
|
onclick={() => removeRow(i)}
|
||||||
|
aria-label={t('settings.removeOverride')}
|
||||||
|
title={t('settings.removeOverride')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiClose" size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button type="button" class="chip-add" onclick={addRow}>
|
||||||
|
<MdiIcon name="mdiPlus" size={13} />
|
||||||
|
<span>{t('settings.addOverride')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div class="preview" role="status">
|
||||||
|
<span class="preview-eyebrow">{t('settings.logPreviewLabel')}</span>
|
||||||
|
<code class="preview-text">{previewLine}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logging {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.15rem;
|
||||||
|
}
|
||||||
|
.log-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.log-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.log-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 38ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.log-row { grid-template-columns: 1fr 1fr; gap: 1.4rem; }
|
||||||
|
}
|
||||||
|
.log-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.log-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Overrides editor --- */
|
||||||
|
.overrides {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.overrides-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.mode-toggle:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.4rem 0.35rem 0.95rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.chip-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.chip[data-tone="sky"] .chip-edge { background: var(--color-sky); }
|
||||||
|
.chip[data-tone="mint"] .chip-edge { background: var(--color-mint); }
|
||||||
|
.chip[data-tone="citrus"] .chip-edge { background: var(--color-citrus); }
|
||||||
|
.chip[data-tone="coral"] .chip-edge { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.chip-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chip-input::placeholder { color: var(--color-muted-foreground); opacity: 0.7; }
|
||||||
|
|
||||||
|
.chip-sep {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-level {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.3rem 1.6rem 0.3rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 7.2rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chip[data-tone="sky"] .chip-level { color: var(--color-sky); border-color: color-mix(in srgb, var(--color-sky) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="mint"] .chip-level { color: var(--color-mint); border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="citrus"] .chip-level { color: var(--color-citrus); border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||||
|
.chip[data-tone="coral"] .chip-level { color: var(--color-coral); border-color: color-mix(in srgb, var(--color-coral) 35%, var(--color-border)); }
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.chip-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 12%, var(--color-glass-strong));
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed var(--color-rule-strong);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.chip-add:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Live preview --- */
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--color-background-deep, #02030a) 30%, var(--color-glass-strong));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.preview-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||||
|
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// All five fields are persisted as strings via the /settings PUT —
|
||||||
|
// the parent owns the boundary type. Bool flags use "0" / "1".
|
||||||
|
providerKind: string;
|
||||||
|
providerUrl: string;
|
||||||
|
providerRepo: string;
|
||||||
|
includePrereleases: string;
|
||||||
|
checkIntervalHours: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
providerKind = $bindable(),
|
||||||
|
providerUrl = $bindable(),
|
||||||
|
providerRepo = $bindable(),
|
||||||
|
includePrereleases = $bindable(),
|
||||||
|
checkIntervalHours = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let checking = $state(false);
|
||||||
|
let testing = $state(false);
|
||||||
|
let testResult = $state<ReleaseTestResult | null>(null);
|
||||||
|
|
||||||
|
const status = $derived(releaseStatusCache.value);
|
||||||
|
const prereleaseChecked = $derived(includePrereleases === '1');
|
||||||
|
const isDisabled = $derived(providerKind === 'disabled');
|
||||||
|
|
||||||
|
// Stale Test-result on input change is misleading — wipe whenever any of
|
||||||
|
// the probed parameters change so the strip reflects "current" state.
|
||||||
|
$effect(() => {
|
||||||
|
// Touch each parameter to register dependency.
|
||||||
|
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
|
||||||
|
testResult = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
|
||||||
|
|
||||||
|
const stateTone: Tone = $derived.by(() => {
|
||||||
|
if (!status) return 'sky';
|
||||||
|
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
|
||||||
|
if (status.update_available) return 'citrus';
|
||||||
|
if (status.provider === 'disabled') return 'sky';
|
||||||
|
return 'mint';
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateLabel = $derived.by(() => {
|
||||||
|
if (!status) return t('settings.release.statusUnknown');
|
||||||
|
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
|
||||||
|
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
|
||||||
|
if (status.update_available) return t('settings.release.statusUpdate');
|
||||||
|
if (status.latest) return t('settings.release.statusUpToDate');
|
||||||
|
return t('settings.release.statusUnknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map backend error taxonomy → localized text. Falls back to the raw code
|
||||||
|
// only when the key is missing (so a new server code surfaces something).
|
||||||
|
function localizedError(code: string | null): string {
|
||||||
|
if (!code) return '';
|
||||||
|
const key = `settings.release.error.${code}`;
|
||||||
|
const localized = t(key);
|
||||||
|
// `t` falls back to the key itself when missing — detect by exact match.
|
||||||
|
return localized === key ? code : localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(iso: string | null): string {
|
||||||
|
if (!iso) return t('settings.release.never');
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (!Number.isFinite(then)) return t('settings.release.never');
|
||||||
|
const diff = Date.now() - then;
|
||||||
|
const min = Math.round(diff / 60_000);
|
||||||
|
if (min < 1) return t('settings.release.justNow');
|
||||||
|
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
|
||||||
|
const h = Math.round(min / 60);
|
||||||
|
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
return t('settings.release.daysAgo').replace('{n}', String(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProvider(kind: ReleaseProviderKind): void {
|
||||||
|
providerKind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIntervalInput(e: Event): void {
|
||||||
|
// The native input emits string values; we keep the contract by
|
||||||
|
// re-coercing to string before assigning to the bindable prop.
|
||||||
|
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNow(): Promise<void> {
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
|
||||||
|
releaseStatusCache.set(next);
|
||||||
|
snackSuccess(t('settings.release.checkDone'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProvider(): Promise<void> {
|
||||||
|
testing = true;
|
||||||
|
testResult = null;
|
||||||
|
try {
|
||||||
|
testResult = await api<ReleaseTestResult>('/settings/release/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider_kind: providerKind,
|
||||||
|
provider_url: providerUrl,
|
||||||
|
provider_repo: providerRepo,
|
||||||
|
include_prereleases: prereleaseChecked,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
|
||||||
|
else snackError(t('settings.release.testFailed'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rel glass" id="release">
|
||||||
|
<header class="rel-head">
|
||||||
|
<div class="rel-eyebrow">
|
||||||
|
<MdiIcon name="mdiUpdate" size={12} />
|
||||||
|
<span>{t('settings.release.eyebrow')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="rel-title">{t('settings.release.headline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rel-body">
|
||||||
|
<!-- 01 Provider — native radios for free keyboard a11y. -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">01</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.release.provider')}
|
||||||
|
<Hint text={t('settings.release.providerHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
|
||||||
|
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="release-provider"
|
||||||
|
value="gitea"
|
||||||
|
checked={providerKind === 'gitea'}
|
||||||
|
onchange={() => setProvider('gitea')}
|
||||||
|
class="seg-radio"
|
||||||
|
/>
|
||||||
|
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
|
||||||
|
</label>
|
||||||
|
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="release-provider"
|
||||||
|
value="github"
|
||||||
|
disabled
|
||||||
|
class="seg-radio"
|
||||||
|
/>
|
||||||
|
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
|
||||||
|
</label>
|
||||||
|
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="release-provider"
|
||||||
|
value="disabled"
|
||||||
|
checked={providerKind === 'disabled'}
|
||||||
|
onchange={() => setProvider('disabled')}
|
||||||
|
class="seg-radio"
|
||||||
|
/>
|
||||||
|
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 02 Repository -->
|
||||||
|
<div class="row" class:row-dim={isDisabled}>
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">02</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.release.repository')}
|
||||||
|
<Hint text={t('settings.release.repositoryHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control repo-grid">
|
||||||
|
<input
|
||||||
|
bind:value={providerUrl}
|
||||||
|
placeholder="https://git.example.com"
|
||||||
|
class="text-input"
|
||||||
|
type="url"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={providerRepo}
|
||||||
|
placeholder="owner/repo"
|
||||||
|
class="text-input mono"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 03 Options — slider toggle for include-prereleases. -->
|
||||||
|
<div class="row" class:row-dim={isDisabled}>
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">03</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.release.options')}
|
||||||
|
<Hint text={t('settings.release.prereleasesHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:toggle-disabled={isDisabled}
|
||||||
|
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
|
||||||
|
aria-pressed={prereleaseChecked}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 04 Check interval -->
|
||||||
|
<div class="row" class:row-dim={isDisabled}>
|
||||||
|
<div class="row-label">
|
||||||
|
<span class="row-num">04</span>
|
||||||
|
<span class="row-name">
|
||||||
|
{t('settings.release.interval')}
|
||||||
|
<Hint text={t('settings.release.intervalHint')} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-control interval">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
value={checkIntervalHours}
|
||||||
|
oninput={onIntervalInput}
|
||||||
|
class="text-input num"
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<span class="unit">{t('settings.release.hoursUnit')}</span>
|
||||||
|
<span class="footnote">{t('settings.release.intervalRange')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State strip -->
|
||||||
|
<footer class="strip" data-tone={stateTone}>
|
||||||
|
<div class="strip-left">
|
||||||
|
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
|
||||||
|
<div class="strip-text">
|
||||||
|
<div class="strip-state">{stateLabel}</div>
|
||||||
|
<div class="strip-meta">
|
||||||
|
<span class="versions">
|
||||||
|
<span class="v-current">v{status?.current ?? '—'}</span>
|
||||||
|
{#if status?.latest && status.latest !== status.current}
|
||||||
|
<span class="arrow" aria-hidden="true">→</span>
|
||||||
|
<span
|
||||||
|
class="v-latest"
|
||||||
|
class:v-latest-update={status.update_available}
|
||||||
|
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="sep" aria-hidden="true">·</span>
|
||||||
|
<span class="checked">
|
||||||
|
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
|
||||||
|
<div class="strip-error">
|
||||||
|
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if testResult && !testResult.ok}
|
||||||
|
<div class="strip-error">
|
||||||
|
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
|
||||||
|
{localizedError(testResult.error)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if testResult && testResult.ok && testResult.info}
|
||||||
|
<div class="strip-test-ok">
|
||||||
|
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
|
||||||
|
<span class="mono">v{testResult.info.version}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="strip-actions">
|
||||||
|
{#if status?.update_available && status.latest_url}
|
||||||
|
<a
|
||||||
|
class="strip-btn strip-btn-cta"
|
||||||
|
href={status.latest_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiOpenInNew" size={13} />
|
||||||
|
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-btn"
|
||||||
|
onclick={testProvider}
|
||||||
|
disabled={testing || isDisabled || !providerRepo}
|
||||||
|
>
|
||||||
|
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
|
||||||
|
<span>{t('settings.release.testConnection')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-btn strip-btn-primary"
|
||||||
|
onclick={checkNow}
|
||||||
|
disabled={checking || isDisabled}
|
||||||
|
>
|
||||||
|
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
|
||||||
|
<span>{t('settings.release.checkNow')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rel {
|
||||||
|
padding: 1.5rem 1.6rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rel-head { position: relative; z-index: 1; }
|
||||||
|
.rel-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.rel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 42ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr;
|
||||||
|
gap: 1.4rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.row:first-child { border-top: 0; padding-top: 0.4rem; }
|
||||||
|
.row-dim { opacity: 0.55; }
|
||||||
|
.row-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.row-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row-control { min-width: 0; }
|
||||||
|
|
||||||
|
/* Segmented provider control — uses real radios so arrow-key + tab
|
||||||
|
navigation just work via the browser. */
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
}
|
||||||
|
.seg-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.seg-radio {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.seg-radio:focus-visible + .seg-content {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.seg-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
transition: background 0.18s, color 0.18s;
|
||||||
|
}
|
||||||
|
.seg-item:hover:not(.seg-soon) .seg-content {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: var(--color-glass);
|
||||||
|
}
|
||||||
|
.seg-active .seg-content {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
.seg-soon { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Text fields */
|
||||||
|
.repo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.text-input.mono { font-family: var(--font-mono); }
|
||||||
|
.text-input.num { max-width: 6rem; text-align: right; }
|
||||||
|
.text-input:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow);
|
||||||
|
}
|
||||||
|
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||||
|
|
||||||
|
/* Interval */
|
||||||
|
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||||
|
.unit {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.footnote {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-thumb {
|
||||||
|
background: white;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.toggle-label-text { font-size: 0.82rem; }
|
||||||
|
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* State strip */
|
||||||
|
.strip {
|
||||||
|
margin: 0 -1.6rem;
|
||||||
|
padding: 1rem 1.6rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg,
|
||||||
|
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.strip[data-tone="citrus"]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 10%,
|
||||||
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
|
||||||
|
transparent 90%
|
||||||
|
);
|
||||||
|
animation: aurora-shimmer 4s linear infinite;
|
||||||
|
}
|
||||||
|
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
|
||||||
|
.dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
|
||||||
|
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
|
||||||
|
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
|
||||||
|
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
|
||||||
|
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||||
|
.strip-state {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.strip-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||||
|
.arrow { color: var(--color-muted-foreground); }
|
||||||
|
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
|
||||||
|
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
|
||||||
|
.sep { opacity: 0.5; }
|
||||||
|
.rel-time { color: var(--color-foreground); }
|
||||||
|
.strip-error {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-coral, #d27a7a);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.strip-test-ok {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-mint, #6fcfa6);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||||
|
.strip-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.18s, border-color 0.18s, transform 0.18s;
|
||||||
|
}
|
||||||
|
.strip-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.strip-btn-primary {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
/* The CTA — high-visibility when an update is available. */
|
||||||
|
.strip-btn-cta {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
|
||||||
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
|
||||||
|
}
|
||||||
|
.strip-btn-cta:hover {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
|
||||||
|
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
@keyframes aurora-shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.strip[data-tone="citrus"]::before { animation: none; }
|
||||||
|
.strip-btn { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.95rem 0;
|
||||||
|
}
|
||||||
|
.row-label { padding-top: 0; }
|
||||||
|
.repo-grid { grid-template-columns: 1fr; }
|
||||||
|
.strip { flex-direction: column; align-items: stretch; }
|
||||||
|
.strip-actions { justify-content: stretch; }
|
||||||
|
.strip-btn { flex: 1; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dirty: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
changedCount: number;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dirty, saving, changedCount, onSave, onDiscard }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if dirty || saving}
|
||||||
|
<div class="save-bar" role="region" aria-label={t('settings.unsavedChanges')}>
|
||||||
|
<div class="save-bar-inner glass">
|
||||||
|
<span class="save-edge" aria-hidden="true"></span>
|
||||||
|
<span class="save-pulse" aria-hidden="true"></span>
|
||||||
|
<div class="save-text">
|
||||||
|
<span class="save-eyebrow">{t('settings.unsaved')}</span>
|
||||||
|
<span class="save-message">
|
||||||
|
{#if changedCount === 1}
|
||||||
|
{t('settings.changedOne')}
|
||||||
|
{:else}
|
||||||
|
{t('settings.changedMany').replace('{n}', String(changedCount))}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="save-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="discard"
|
||||||
|
onclick={onDiscard}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('settings.discard')}
|
||||||
|
</button>
|
||||||
|
<Button size="sm" onclick={onSave} disabled={saving}>
|
||||||
|
{#if saving}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiContentSave" size={14} />
|
||||||
|
{/if}
|
||||||
|
{saving ? t('common.loading') : t('settings.saveChanges')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.save-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 40;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: save-rise 0.3s cubic-bezier(.2,.7,.2,1) both;
|
||||||
|
}
|
||||||
|
.save-bar-inner {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 1rem 0.7rem 1.25rem;
|
||||||
|
max-width: min(640px, calc(100% - 1rem));
|
||||||
|
width: 100%;
|
||||||
|
border-color: color-mix(in srgb, var(--color-citrus) 40%, var(--color-border));
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-card),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-citrus) 22%, transparent) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.save-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(180deg, var(--color-citrus), color-mix(in srgb, var(--color-citrus) 50%, transparent));
|
||||||
|
}
|
||||||
|
.save-pulse {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-citrus);
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent);
|
||||||
|
animation: save-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-text {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 1rem; /* clear room for the pulse dot */
|
||||||
|
}
|
||||||
|
.save-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-citrus);
|
||||||
|
}
|
||||||
|
.save-message {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.discard {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.discard:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
.discard:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
@keyframes save-rise {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes save-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-citrus) 60%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-citrus) 0%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.save-bar, .save-pulse { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
|
||||||
|
import { releaseStatusCache } from '$lib/stores/caches.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
external_url: string;
|
||||||
|
timezone: string;
|
||||||
|
supported_locales: string;
|
||||||
|
log_level: string;
|
||||||
|
log_format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { settings }: Props = $props();
|
||||||
|
|
||||||
|
// Live tick so the timezone pill shows the current local HH:MM.
|
||||||
|
let now = $state(new Date());
|
||||||
|
let tick: ReturnType<typeof setInterval> | null = null;
|
||||||
|
onMount(() => { tick = setInterval(() => { now = new Date(); }, 30_000); });
|
||||||
|
onDestroy(() => { if (tick) clearInterval(tick); });
|
||||||
|
|
||||||
|
function fmtClock(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz || 'UTC',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
} catch { return '--:--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromUrl(url: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
try { return new URL(url).host; }
|
||||||
|
catch { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeCount(csv: string): number {
|
||||||
|
if (!csv) return 0;
|
||||||
|
return csv.split(',').map(s => s.trim()).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_TONE: Record<string, Tone> = {
|
||||||
|
DEBUG: 'sky',
|
||||||
|
INFO: 'mint',
|
||||||
|
WARNING: 'citrus',
|
||||||
|
ERROR: 'coral',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pills = $derived.by<HeaderPill[]>(() => {
|
||||||
|
const out: HeaderPill[] = [];
|
||||||
|
|
||||||
|
const host = hostFromUrl(settings.external_url);
|
||||||
|
out.push(host
|
||||||
|
? { label: host, tone: 'sky' }
|
||||||
|
: { label: t('settings.heroNoUrl') }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tz = settings.timezone || 'UTC';
|
||||||
|
out.push({ label: `${tz} · ${fmtClock(tz)}`, tone: 'primary' });
|
||||||
|
|
||||||
|
const locales = settings.supported_locales || '';
|
||||||
|
const count = localeCount(locales);
|
||||||
|
out.push({
|
||||||
|
label: count > 0
|
||||||
|
? locales.split(',').map(s => s.trim()).filter(Boolean).map(s => s.toUpperCase()).join(' · ')
|
||||||
|
: t('settings.heroNoLocales'),
|
||||||
|
tone: 'orchid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lvl = (settings.log_level || 'INFO').toUpperCase();
|
||||||
|
out.push({
|
||||||
|
label: `${lvl} · ${settings.log_format || 'text'}`,
|
||||||
|
tone: SEVERITY_TONE[lvl] ?? 'mint',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rs = releaseStatusCache.value;
|
||||||
|
if (rs) {
|
||||||
|
if (rs.provider === 'disabled') {
|
||||||
|
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
|
||||||
|
} else if (rs.error && rs.error !== 'provider_changed') {
|
||||||
|
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
|
||||||
|
} else if (rs.update_available && rs.latest) {
|
||||||
|
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
|
||||||
|
} else if (rs.latest) {
|
||||||
|
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={t('settings.title')}
|
||||||
|
emphasis={t('settings.titleEmphasis')}
|
||||||
|
description={t('settings.description')}
|
||||||
|
crumb={t('crumbs.systemConfiguration')}
|
||||||
|
{pills}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
webhookSecret: string;
|
||||||
|
cacheTtlHours: string;
|
||||||
|
cacheMaxEntries: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
webhookSecret = $bindable(),
|
||||||
|
cacheTtlHours = $bindable(),
|
||||||
|
cacheMaxEntries = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showSecret = $state(false);
|
||||||
|
|
||||||
|
const secretSet = $derived(!!webhookSecret && webhookSecret.length > 0);
|
||||||
|
const ttlHours = $derived(Number(cacheTtlHours || '0'));
|
||||||
|
const ttlIsOff = $derived(ttlHours <= 0);
|
||||||
|
|
||||||
|
function ttlHumanized(h: number): string {
|
||||||
|
if (h <= 0) return t('settings.ttlNoExpiry');
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
if (d < 7) return `${d}d`;
|
||||||
|
const w = Math.round(d / 7);
|
||||||
|
if (w < 8) return `${w}w`;
|
||||||
|
const mo = Math.round(d / 30);
|
||||||
|
return `${mo}mo`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="tg glass">
|
||||||
|
<header class="tg-head">
|
||||||
|
<div class="tg-eyebrow">
|
||||||
|
<MdiIcon name="mdiSend" size={12} />
|
||||||
|
<span>{t('settings.telegram')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="tg-title">{t('settings.telegramHeadline')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tg-grid">
|
||||||
|
<!-- Webhook secret column -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="col-num">A</span>
|
||||||
|
<span class="col-name">
|
||||||
|
{t('settings.webhookSecret')}
|
||||||
|
<Hint text={t('settings.webhookSecretHint')} />
|
||||||
|
</span>
|
||||||
|
<span class="col-status" data-state={secretSet ? 'set' : 'unset'}>
|
||||||
|
<span class="dot"></span>
|
||||||
|
{secretSet ? t('settings.secretSet') : t('settings.secretUnset')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form class="secret-field" onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||||
|
<input
|
||||||
|
bind:value={webhookSecret}
|
||||||
|
type={showSecret ? 'text' : 'password'}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={t('providers.optional')}
|
||||||
|
class="secret-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secret-toggle"
|
||||||
|
onclick={() => (showSecret = !showSecret)}
|
||||||
|
aria-label={showSecret ? t('settings.hide') : t('settings.show')}
|
||||||
|
title={showSecret ? t('settings.hide') : t('settings.show')}
|
||||||
|
>
|
||||||
|
<MdiIcon name={showSecret ? 'mdiEyeOff' : 'mdiEye'} size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache config column -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<span class="col-num">B</span>
|
||||||
|
<span class="col-name">{t('settings.cacheConfig')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cache-grid">
|
||||||
|
<label class="num-field">
|
||||||
|
<span class="num-label">
|
||||||
|
{t('settings.cacheTtlShort')}
|
||||||
|
<Hint text={t('settings.cacheTtlHint')} />
|
||||||
|
</span>
|
||||||
|
<div class="num-row">
|
||||||
|
<input
|
||||||
|
bind:value={cacheTtlHours}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="8760"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
<span class="num-suffix">{t('settings.hoursShort')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-meta" class:num-meta-off={ttlIsOff}>
|
||||||
|
{ttlHumanized(ttlHours)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="num-field">
|
||||||
|
<span class="num-label">
|
||||||
|
{t('settings.cacheMaxShort')}
|
||||||
|
<Hint text={t('settings.cacheMaxEntriesHint')} />
|
||||||
|
</span>
|
||||||
|
<div class="num-row">
|
||||||
|
<input
|
||||||
|
bind:value={cacheMaxEntries}
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="100000"
|
||||||
|
class="num-input"
|
||||||
|
/>
|
||||||
|
<span class="num-suffix">{t('settings.entriesShort')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="num-meta">
|
||||||
|
{t('settings.cacheMaxFootnote')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tg {
|
||||||
|
padding: 1.5rem 1.6rem 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.15rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.tg-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.tg-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.tg-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.tg-grid { grid-template-columns: 1fr 1fr; gap: 1.6rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.col-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.col-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.col-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.col-status {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.col-status .dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.col-status[data-state="set"] {
|
||||||
|
color: var(--color-mint);
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-mint) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.col-status[data-state="set"] .dot {
|
||||||
|
background: var(--color-mint);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Secret field --- */
|
||||||
|
.secret-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.15rem 0.35rem 0.15rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.secret-field:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow);
|
||||||
|
}
|
||||||
|
.secret-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.secret-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.secret-toggle:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cache config grid --- */
|
||||||
|
.cache-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.num-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.7rem 0.85rem 0.65rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.num-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.num-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.num-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: 1.1;
|
||||||
|
outline: none;
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.num-input::-webkit-outer-spin-button,
|
||||||
|
.num-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.num-suffix {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.num-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-mint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.num-meta-off {
|
||||||
|
color: var(--color-citrus);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.cache-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, fetchAuth } from '$lib/api';
|
import { api, fetchAuth , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import Card from '$lib/components/Card.svelte';
|
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
@@ -11,32 +9,55 @@
|
|||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
// --- Export state ---
|
import BackupHero from './BackupHero.svelte';
|
||||||
let exportSecrets = $state('exclude');
|
import PendingStrip from './PendingStrip.svelte';
|
||||||
let exporting = $state(false);
|
import ExportPanel from './ExportPanel.svelte';
|
||||||
|
import ImportPanel from './ImportPanel.svelte';
|
||||||
|
import ScheduleCassette from './ScheduleCassette.svelte';
|
||||||
|
import BackupLedger from './BackupLedger.svelte';
|
||||||
|
|
||||||
const categories = [
|
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||||
{ key: 'providers', label: 'backup.catProviders' },
|
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||||
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
|
||||||
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
interface BackupFile {
|
||||||
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
filename: string;
|
||||||
{ key: 'targets', label: 'backup.catTargets' },
|
size: number;
|
||||||
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
created_at?: string | null;
|
||||||
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
}
|
||||||
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
|
||||||
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
interface ScheduledSettings {
|
||||||
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
backup_scheduled_enabled: string;
|
||||||
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
backup_scheduled_interval_hours: string;
|
||||||
{ key: 'actions', label: 'backup.catActions' },
|
backup_secrets_mode: string;
|
||||||
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
backup_retention_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingState {
|
||||||
|
pending: boolean;
|
||||||
|
uploaded_at?: string | null;
|
||||||
|
uploaded_by?: string | null;
|
||||||
|
conflict_mode?: string;
|
||||||
|
supervised?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCategories = [
|
||||||
|
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
|
||||||
|
'tracking_configs', 'template_configs',
|
||||||
|
'command_configs', 'command_template_configs',
|
||||||
|
'notification_trackers', 'command_trackers',
|
||||||
|
'actions', 'app_settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Export state ---
|
||||||
|
let exportSecrets = $state<SecretsMode>('exclude');
|
||||||
|
let exporting = $state(false);
|
||||||
let selectedCategories = $state<Record<string, boolean>>(
|
let selectedCategories = $state<Record<string, boolean>>(
|
||||||
Object.fromEntries(categories.map(c => [c.key, true]))
|
Object.fromEntries(allCategories.map(k => [k, true]))
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Import state ---
|
// --- Import state ---
|
||||||
let importFile: File | null = $state(null);
|
let importFile: File | null = $state(null);
|
||||||
let importConflict = $state('skip');
|
let importConflict = $state<ConflictMode>('skip');
|
||||||
let importing = $state(false);
|
let importing = $state(false);
|
||||||
let validating = $state(false);
|
let validating = $state(false);
|
||||||
let validationResult: any = $state(null);
|
let validationResult: any = $state(null);
|
||||||
@@ -47,7 +68,7 @@
|
|||||||
// --- Scheduled backup state ---
|
// --- Scheduled backup state ---
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let scheduledSettings = $state({
|
let scheduledSettings = $state<ScheduledSettings>({
|
||||||
backup_scheduled_enabled: 'false',
|
backup_scheduled_enabled: 'false',
|
||||||
backup_scheduled_interval_hours: '24',
|
backup_scheduled_interval_hours: '24',
|
||||||
backup_secrets_mode: 'exclude',
|
backup_secrets_mode: 'exclude',
|
||||||
@@ -56,50 +77,50 @@
|
|||||||
let savingSchedule = $state(false);
|
let savingSchedule = $state(false);
|
||||||
|
|
||||||
// --- Backup files ---
|
// --- Backup files ---
|
||||||
let backupFiles = $state<any[]>([]);
|
let backupFiles = $state<BackupFile[]>([]);
|
||||||
let loadingFiles = $state(false);
|
let loadingFiles = $state(false);
|
||||||
let confirmDeleteFile = $state('');
|
let confirmDeleteFile = $state('');
|
||||||
let creatingBackup = $state(false);
|
let creatingBackup = $state(false);
|
||||||
|
|
||||||
// --- Pending restore state ---
|
// --- Pending restore state ---
|
||||||
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
let pending = $state<PendingState | null>(null);
|
||||||
let postRestoreModalOpen = $state(false);
|
let postRestoreModalOpen = $state(false);
|
||||||
let restartingOverlay = $state(false);
|
let restartingOverlay = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const [settings, files, p] = await Promise.all([
|
const [settings, files, p] = await Promise.all([
|
||||||
api('/backup/scheduled'),
|
api<ScheduledSettings>('/backup/scheduled'),
|
||||||
api('/backup/files'),
|
api<BackupFile[]>('/backup/files'),
|
||||||
api('/backup/pending-restore'),
|
api<PendingState>('/backup/pending-restore'),
|
||||||
]);
|
]);
|
||||||
scheduledSettings = settings;
|
scheduledSettings = settings;
|
||||||
backupFiles = files;
|
backupFiles = files;
|
||||||
pending = p;
|
pending = p;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function cancelPending() {
|
async function cancelPending(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||||
snackSuccess(t('backup.pendingCancelled'));
|
snackSuccess(t('backup.pendingCancelled'));
|
||||||
pending = null;
|
pending = null;
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAndRestart() {
|
async function applyAndRestart(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api('/backup/apply-restart', { method: 'POST' });
|
await api('/backup/apply-restart', { method: 'POST' });
|
||||||
restartingOverlay = true;
|
restartingOverlay = true;
|
||||||
// Poll /health until the new instance is up
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const poll = async () => {
|
const poll = async (): Promise<void> => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health');
|
const res = await fetch('/api/health');
|
||||||
@@ -111,28 +132,28 @@
|
|||||||
if (attempts < 120) setTimeout(poll, 1000);
|
if (attempts < 120) setTimeout(poll, 1000);
|
||||||
};
|
};
|
||||||
setTimeout(poll, 1500);
|
setTimeout(poll, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
restartingOverlay = false;
|
restartingOverlay = false;
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManualBackup() {
|
async function createManualBackup(): Promise<void> {
|
||||||
creatingBackup = true;
|
creatingBackup = true;
|
||||||
try {
|
try {
|
||||||
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||||
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||||
snackSuccess(t('backup.manualCreated'));
|
snackSuccess(t('backup.manualCreated'));
|
||||||
await refreshFiles();
|
await refreshFiles();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
creatingBackup = false;
|
creatingBackup = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Export ---
|
// --- Export ---
|
||||||
async function doExport() {
|
async function doExport(): Promise<void> {
|
||||||
if (exportSecrets === 'include') {
|
if (exportSecrets === 'include') {
|
||||||
confirmExportOpen = true;
|
confirmExportOpen = true;
|
||||||
return;
|
return;
|
||||||
@@ -140,7 +161,7 @@
|
|||||||
await performExport();
|
await performExport();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performExport() {
|
async function performExport(): Promise<void> {
|
||||||
confirmExportOpen = false;
|
confirmExportOpen = false;
|
||||||
exporting = true;
|
exporting = true;
|
||||||
try {
|
try {
|
||||||
@@ -158,15 +179,21 @@
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
snackSuccess(t('backup.exportSuccess'));
|
snackSuccess(t('backup.exportSuccess'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
exporting = false;
|
exporting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Validate ---
|
// --- Validate / Import ---
|
||||||
async function validateFile() {
|
function handleFileSelect(file: File | null): void {
|
||||||
|
importFile = file;
|
||||||
|
validationResult = null;
|
||||||
|
importResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateFile(): Promise<void> {
|
||||||
if (!importFile) return;
|
if (!importFile) return;
|
||||||
validating = true;
|
validating = true;
|
||||||
validationResult = null;
|
validationResult = null;
|
||||||
@@ -176,19 +203,18 @@
|
|||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||||
validationResult = await res.json();
|
validationResult = await res.json();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
validating = false;
|
validating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Import ---
|
function doImport(): void {
|
||||||
async function doImport() {
|
|
||||||
confirmImportOpen = true;
|
confirmImportOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performImport() {
|
async function performImport(): Promise<void> {
|
||||||
confirmImportOpen = false;
|
confirmImportOpen = false;
|
||||||
if (!importFile) return;
|
if (!importFile) return;
|
||||||
importing = true;
|
importing = true;
|
||||||
@@ -205,42 +231,42 @@
|
|||||||
snackSuccess(t('backup.restorePrepared'));
|
snackSuccess(t('backup.restorePrepared'));
|
||||||
postRestoreModalOpen = true;
|
postRestoreModalOpen = true;
|
||||||
importFile = null;
|
importFile = null;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
importing = false;
|
importing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Scheduled settings ---
|
// --- Scheduled settings ---
|
||||||
async function saveSchedule() {
|
async function saveSchedule(): Promise<void> {
|
||||||
savingSchedule = true;
|
savingSchedule = true;
|
||||||
try {
|
try {
|
||||||
scheduledSettings = await api('/backup/scheduled', {
|
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(scheduledSettings),
|
body: JSON.stringify(scheduledSettings),
|
||||||
});
|
});
|
||||||
snackSuccess(t('backup.scheduleSaved'));
|
snackSuccess(t('backup.scheduleSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
savingSchedule = false;
|
savingSchedule = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- File management ---
|
// --- File management ---
|
||||||
async function refreshFiles() {
|
async function refreshFiles(): Promise<void> {
|
||||||
loadingFiles = true;
|
loadingFiles = true;
|
||||||
try {
|
try {
|
||||||
backupFiles = await api('/backup/files');
|
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
loadingFiles = false;
|
loadingFiles = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(filename: string) {
|
async function downloadFile(filename: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await api(`/backup/files/${filename}`);
|
const data = await api(`/backup/files/${filename}`);
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
@@ -250,370 +276,76 @@
|
|||||||
a.download = filename;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(filename: string) {
|
async function deleteFile(filename: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
||||||
snackSuccess(t('backup.fileDeleted'));
|
snackSuccess(t('backup.fileDeleted'));
|
||||||
confirmDeleteFile = '';
|
confirmDeleteFile = '';
|
||||||
await refreshFiles();
|
await refreshFiles();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
if (input.files?.length) {
|
|
||||||
importFile = input.files[0];
|
|
||||||
validationResult = null;
|
|
||||||
importResult = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
|
||||||
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
|
||||||
|
|
||||||
function toggleAll() {
|
|
||||||
const newVal = !allSelected;
|
|
||||||
for (const key of Object.keys(selectedCategories)) {
|
|
||||||
selectedCategories[key] = newVal;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
|
||||||
title={t('backup.title')}
|
|
||||||
emphasis={t('backup.titleEmphasis')}
|
|
||||||
description={t('backup.description')}
|
|
||||||
crumb="System · Maintenance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
{#if pending?.pending}
|
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
|
||||||
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
|
||||||
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
<div class="backup-page stagger-children">
|
||||||
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
<div class="action-deck">
|
||||||
<MdiIcon name="mdiClockAlert" size={20} />
|
<ExportPanel
|
||||||
</span>
|
{selectedCategories}
|
||||||
<div class="flex-1 min-w-[12rem] text-sm">
|
{exportSecrets}
|
||||||
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
{exporting}
|
||||||
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
onCategoriesChange={(next) => selectedCategories = next}
|
||||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
onSecretsChange={(next) => exportSecrets = next}
|
||||||
</div>
|
onExport={doExport}
|
||||||
</div>
|
/>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<ImportPanel
|
||||||
{#if pending.supervised}
|
{importFile}
|
||||||
<Button size="sm" onclick={applyAndRestart}>
|
{importConflict}
|
||||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
{validating}
|
||||||
</Button>
|
{validationResult}
|
||||||
{/if}
|
{importing}
|
||||||
<button onclick={cancelPending}
|
{importResult}
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
onFileSelect={handleFileSelect}
|
||||||
{t('common.cancel')}
|
onConflictChange={(mode) => importConflict = mode}
|
||||||
</button>
|
onValidate={validateFile}
|
||||||
</div>
|
onImport={doImport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<ScheduleCassette
|
||||||
|
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||||
|
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
|
||||||
|
bind:secretsMode={scheduledSettings.backup_secrets_mode}
|
||||||
|
bind:retentionCount={scheduledSettings.backup_retention_count}
|
||||||
|
saving={savingSchedule}
|
||||||
|
onToggle={() => scheduledSettings.backup_scheduled_enabled =
|
||||||
|
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
|
||||||
|
onSave={saveSchedule}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Export Section -->
|
<BackupLedger
|
||||||
<Card>
|
files={backupFiles}
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
loading={loadingFiles}
|
||||||
<MdiIcon name="mdiDatabaseExport" size={18} />
|
creating={creatingBackup}
|
||||||
{t('backup.export')}
|
onCreate={createManualBackup}
|
||||||
</h3>
|
onRefresh={refreshFiles}
|
||||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
onDownload={downloadFile}
|
||||||
|
onDelete={(filename) => confirmDeleteFile = filename}
|
||||||
<!-- Categories -->
|
/>
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="text-xs font-medium">{t('backup.categories')}</span>
|
|
||||||
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
|
||||||
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
|
||||||
{#each categories as cat}
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
|
||||||
{t(cat.label)}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secrets mode -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
|
||||||
{t('backup.secretsExclude')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={exportSecrets} value="masked" />
|
|
||||||
{t('backup.secretsMasked')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={exportSecrets} value="include" />
|
|
||||||
{t('backup.secretsInclude')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{#if exportSecrets === 'include'}
|
|
||||||
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
|
||||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiAlert" size={14} />
|
|
||||||
{t('backup.secretsWarningExport')}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
|
||||||
{#if exporting}
|
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiDownload" size={14} />
|
|
||||||
{/if}
|
|
||||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Import Section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiDatabaseImport" size={18} />
|
|
||||||
{t('backup.import')}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
|
||||||
|
|
||||||
<!-- File picker -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input type="file" accept=".json" onchange={handleFileSelect}
|
|
||||||
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
|
||||||
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if importFile}
|
|
||||||
<!-- Validate -->
|
|
||||||
<div class="mb-4 flex items-center gap-2">
|
|
||||||
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
|
||||||
{#if validating}
|
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
|
||||||
{/if}
|
|
||||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if validationResult}
|
|
||||||
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
||||||
<div class="flex items-center gap-2 mb-2 font-medium">
|
|
||||||
{#if validationResult.valid}
|
|
||||||
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
|
||||||
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
|
||||||
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if Object.keys(validationResult.entity_counts || {}).length}
|
|
||||||
<div class="mb-2">
|
|
||||||
<span class="font-medium">{t('backup.entities')}:</span>
|
|
||||||
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
|
||||||
<span class="inline-block mr-2">{cat}: {count}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each validationResult.warnings || [] as w}
|
|
||||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
|
||||||
<MdiIcon name="mdiAlert" size={12} />
|
|
||||||
<span>{w}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#each validationResult.errors || [] as e}
|
|
||||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiAlertCircle" size={12} />
|
|
||||||
<span>{e}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Conflict mode -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="skip" />
|
|
||||||
{t('backup.conflictSkip')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="rename" />
|
|
||||||
{t('backup.conflictRename')}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 text-xs">
|
|
||||||
<input type="radio" bind:group={importConflict} value="overwrite" />
|
|
||||||
{t('backup.conflictOverwrite')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onclick={doImport}
|
|
||||||
disabled={importing || !validationResult?.valid}>
|
|
||||||
{#if importing}
|
|
||||||
<MdiIcon name="mdiLoading" size={14} />
|
|
||||||
{:else}
|
|
||||||
<MdiIcon name="mdiUpload" size={14} />
|
|
||||||
{/if}
|
|
||||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{#if importResult}
|
|
||||||
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
|
||||||
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
|
||||||
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
|
||||||
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
|
||||||
{#if importResult.errors?.length}
|
|
||||||
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
|
||||||
{#each importResult.errors as e}
|
|
||||||
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{#if importResult.warnings?.length}
|
|
||||||
{#each importResult.warnings as w}
|
|
||||||
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Scheduled Backups Section -->
|
|
||||||
<Card>
|
|
||||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiClockOutline" size={18} />
|
|
||||||
{t('backup.scheduled')}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="flex items-center gap-2 text-xs">
|
|
||||||
<input type="checkbox"
|
|
||||||
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
||||||
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
|
||||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
|
||||||
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
|
||||||
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="6">6 {t('backup.hours')}</option>
|
|
||||||
<option value="12">12 {t('backup.hours')}</option>
|
|
||||||
<option value="24">24 {t('backup.hours')}</option>
|
|
||||||
<option value="48">48 {t('backup.hours')}</option>
|
|
||||||
<option value="72">72 {t('backup.hours')}</option>
|
|
||||||
<option value="168">168 {t('backup.hours')} (7d)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
|
||||||
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="exclude">{t('backup.secretsExclude')}</option>
|
|
||||||
<option value="masked">{t('backup.secretsMasked')}</option>
|
|
||||||
<option value="include">{t('backup.secretsInclude')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
|
||||||
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
|
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
|
||||||
<option value="3">3</option>
|
|
||||||
<option value="5">5</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="20">20</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
|
||||||
{savingSchedule ? t('common.loading') : t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Saved Backup Files -->
|
|
||||||
<Card>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiFolder" size={18} />
|
|
||||||
{t('backup.savedFiles')}
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
|
||||||
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
|
||||||
</Button>
|
|
||||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
|
||||||
<MdiIcon name="mdiRefresh" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if backupFiles.length === 0}
|
|
||||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each backupFiles as file}
|
|
||||||
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
|
||||||
style="border-color: var(--color-border);">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<MdiIcon name="mdiFileDocument" size={14} />
|
|
||||||
<span class="font-mono">{file.filename}</span>
|
|
||||||
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button onclick={() => downloadFile(file.filename)}
|
|
||||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
|
||||||
<MdiIcon name="mdiDownload" size={14} />
|
|
||||||
</button>
|
|
||||||
<button onclick={() => confirmDeleteFile = file.filename}
|
|
||||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
|
||||||
style="color: var(--color-error-fg);">
|
|
||||||
<MdiIcon name="mdiDelete" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -652,27 +384,25 @@
|
|||||||
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||||
{#if postRestoreModalOpen && pending?.pending}
|
{#if postRestoreModalOpen && pending?.pending}
|
||||||
<div class="post-restore-backdrop"
|
<div class="post-restore-backdrop"
|
||||||
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
|
||||||
onclick={() => postRestoreModalOpen = false}
|
onclick={() => postRestoreModalOpen = false}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||||
role="presentation">
|
role="presentation">
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
||||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
class="post-restore-card"
|
||||||
onclick={(e) => e.stopPropagation()}>
|
onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="post-restore-head">
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
<div class="post-restore-icon">
|
||||||
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
|
||||||
<MdiIcon name="mdiClockAlert" size={22} />
|
<MdiIcon name="mdiClockAlert" size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="post-restore-text">
|
||||||
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
|
||||||
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
<p>{t('backup.restoreApplyPrompt')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end flex-wrap">
|
<div class="post-restore-actions">
|
||||||
<button onclick={() => postRestoreModalOpen = false}
|
<button class="post-restore-later" type="button"
|
||||||
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
onclick={() => postRestoreModalOpen = false}>
|
||||||
{t('backup.applyLater')}
|
{t('backup.applyLater')}
|
||||||
</button>
|
</button>
|
||||||
{#if pending.supervised}
|
{#if pending.supervised}
|
||||||
@@ -687,30 +417,162 @@
|
|||||||
|
|
||||||
<!-- Restarting overlay -->
|
<!-- Restarting overlay -->
|
||||||
{#if restartingOverlay}
|
{#if restartingOverlay}
|
||||||
<div role="alert" aria-live="assertive"
|
<div class="restart-overlay" role="alert" aria-live="assertive">
|
||||||
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
<div class="restart-card">
|
||||||
<div class="text-center p-6" style="color: var(--color-foreground);">
|
<div class="restart-spinner">
|
||||||
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
|
||||||
<MdiIcon name="mdiRestart" size={40} />
|
<MdiIcon name="mdiRestart" size={40} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
<p class="restart-title">{t('backup.restartingTitle')}</p>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
<p class="restart-sub">{t('backup.restartingDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.backup-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-deck {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.action-deck { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post-restore modal */
|
||||||
|
.post-restore-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.post-restore-card {
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.post-restore-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
.post-restore-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning-fg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.post-restore-text { min-width: 0; }
|
||||||
|
.post-restore-text h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.post-restore-text p {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.post-restore-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.post-restore-later {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.post-restore-later:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restarting overlay */
|
||||||
|
.restart-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.restart-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.6rem 2rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
.restart-spinner {
|
.restart-spinner {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: var(--color-primary);
|
||||||
animation: restart-spin 1.2s linear infinite;
|
animation: restart-spin 1.2s linear infinite;
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
.restart-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.restart-sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
}
|
||||||
@keyframes restart-spin {
|
@keyframes restart-spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.restart-spinner { animation: none !important; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduledSettings {
|
||||||
|
backup_scheduled_enabled: string;
|
||||||
|
backup_scheduled_interval_hours: string;
|
||||||
|
backup_secrets_mode: string;
|
||||||
|
backup_retention_count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: BackupFile[];
|
||||||
|
scheduled: ScheduledSettings;
|
||||||
|
pending: { pending: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files, scheduled, pending }: Props = $props();
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestCreatedAt(list: BackupFile[]): string | null {
|
||||||
|
const stamps = list
|
||||||
|
.map(f => f.created_at)
|
||||||
|
.filter((s): s is string => !!s)
|
||||||
|
.sort();
|
||||||
|
return stamps.length ? stamps[stamps.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageHours(iso: string | null): number {
|
||||||
|
if (!iso) return Infinity;
|
||||||
|
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
if (isNaN(date.getTime())) return Infinity;
|
||||||
|
return (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
|
||||||
|
const out: Array<{ label: string; tone?: Tone }> = [];
|
||||||
|
if (pending?.pending) {
|
||||||
|
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
|
||||||
|
}
|
||||||
|
if (scheduled.backup_scheduled_enabled === 'true') {
|
||||||
|
out.push({
|
||||||
|
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
|
||||||
|
tone: 'mint',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({ label: t('backup.scheduleOff') });
|
||||||
|
}
|
||||||
|
const latest = latestCreatedAt(files);
|
||||||
|
if (latest) {
|
||||||
|
const hours = ageHours(latest);
|
||||||
|
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
|
||||||
|
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
|
||||||
|
} else {
|
||||||
|
out.push({ label: t('backup.never'), tone: 'citrus' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={t('backup.title')}
|
||||||
|
emphasis={t('backup.titleEmphasis')}
|
||||||
|
description={t('backup.description')}
|
||||||
|
crumb={t('crumbs.systemMaintenance')}
|
||||||
|
count={files.length}
|
||||||
|
countLabel={t('backup.countLabel')}
|
||||||
|
{pills}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface BackupFile {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: BackupFile[];
|
||||||
|
loading: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
onCreate: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onDownload: (filename: string) => void;
|
||||||
|
onDelete: (filename: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(iso: string | null | undefined): Date | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return '';
|
||||||
|
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diffSec < 60) return t('dashboard.justNow');
|
||||||
|
const min = Math.floor(diffSec / 60);
|
||||||
|
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteTime(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
return date ? date.toLocaleString() : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageTone(iso: string | null | undefined): Tone {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
if (!date) return 'coral';
|
||||||
|
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||||
|
if (hours < 48) return 'mint';
|
||||||
|
if (hours < 24 * 7) return 'sky';
|
||||||
|
if (hours < 24 * 30) return 'citrus';
|
||||||
|
return 'coral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="ledger glass">
|
||||||
|
<header class="ledger-head">
|
||||||
|
<div>
|
||||||
|
<div class="ledger-eyebrow">
|
||||||
|
<MdiIcon name="mdiArchiveOutline" size={12} />
|
||||||
|
<span>{t('backup.savedFiles')}</span>
|
||||||
|
</div>
|
||||||
|
{#if files.length > 0}
|
||||||
|
<div class="ledger-summary">
|
||||||
|
<span class="ledger-count font-mono">{files.length}</span>
|
||||||
|
<span class="ledger-count-label">{t('backup.countLabel')}</span>
|
||||||
|
<span class="ledger-sep">·</span>
|
||||||
|
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
|
||||||
|
{#if creating}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
{/if}
|
||||||
|
{creating ? t('common.loading') : t('backup.createManual')}
|
||||||
|
</Button>
|
||||||
|
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
|
||||||
|
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
|
||||||
|
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="ledger-empty">
|
||||||
|
<MdiIcon name="mdiCloudOffOutline" size={28} />
|
||||||
|
<p>{t('backup.noFiles')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ol class="ledger-list">
|
||||||
|
{#each files as file (file.filename)}
|
||||||
|
{@const tone = ageTone(file.created_at)}
|
||||||
|
<li class="row" data-tone={tone}>
|
||||||
|
<span class="row-edge" aria-hidden="true"></span>
|
||||||
|
<span class="row-dot" aria-hidden="true"></span>
|
||||||
|
<div class="row-time">
|
||||||
|
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
|
||||||
|
<span class="row-abs" title={absoluteTime(file.created_at)}>
|
||||||
|
{absoluteTime(file.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-name">
|
||||||
|
<span class="row-filename" title={file.filename}>{file.filename}</span>
|
||||||
|
</div>
|
||||||
|
<span class="row-size font-mono">{formatBytes(file.size)}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="icon-btn" type="button"
|
||||||
|
onclick={() => onDownload(file.filename)}
|
||||||
|
aria-label={t('backup.download')}
|
||||||
|
title={t('backup.download')}>
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn icon-btn-danger" type="button"
|
||||||
|
onclick={() => onDelete(file.filename)}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
title={t('common.delete')}>
|
||||||
|
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ledger {
|
||||||
|
padding: 1.4rem 1.5rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.95rem;
|
||||||
|
}
|
||||||
|
.ledger-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ledger-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.ledger-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ledger-count {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ledger-count-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||||
|
.ledger-total {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ledger-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.icon-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.icon-btn-danger:hover:not(:disabled) {
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.spinning {
|
||||||
|
display: inline-flex;
|
||||||
|
animation: ledger-spin 1.1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ledger-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-empty {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.6rem 1rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ledger-empty p { margin: 0; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.ledger-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
background: var(--color-glass-elev);
|
||||||
|
}
|
||||||
|
.row-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||||
|
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||||
|
.row-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||||
|
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
|
||||||
|
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
|
||||||
|
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
|
||||||
|
|
||||||
|
.row-time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
min-width: 6.5rem;
|
||||||
|
}
|
||||||
|
.row-rel {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.row-abs {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-filename {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.row:hover .row-filename { color: var(--color-foreground); }
|
||||||
|
|
||||||
|
.row-size {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s;
|
||||||
|
}
|
||||||
|
.row:hover .row-actions,
|
||||||
|
.row:focus-within .row-actions { opacity: 1; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
|
||||||
|
.row-time { grid-column: 2; min-width: 0; }
|
||||||
|
.row-name { grid-column: 1 / -1; }
|
||||||
|
.row-size { grid-column: 3; grid-row: 1; }
|
||||||
|
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.row { transition: none !important; }
|
||||||
|
.row:hover { transform: none !important; }
|
||||||
|
.spinning { animation: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCategories: Record<string, boolean>;
|
||||||
|
exportSecrets: SecretsMode;
|
||||||
|
exporting: boolean;
|
||||||
|
onCategoriesChange: (next: Record<string, boolean>) => void;
|
||||||
|
onSecretsChange: (next: SecretsMode) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedCategories,
|
||||||
|
exportSecrets,
|
||||||
|
exporting,
|
||||||
|
onCategoriesChange,
|
||||||
|
onSecretsChange,
|
||||||
|
onExport,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
|
||||||
|
{
|
||||||
|
key: 'identity',
|
||||||
|
labelKey: 'backup.catGroupIdentity',
|
||||||
|
icon: 'mdiAccountNetwork',
|
||||||
|
cats: [
|
||||||
|
{ key: 'providers', labelKey: 'backup.catProviders' },
|
||||||
|
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
|
||||||
|
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
|
||||||
|
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
|
||||||
|
{ key: 'targets', labelKey: 'backup.catTargets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notif',
|
||||||
|
labelKey: 'backup.catGroupNotif',
|
||||||
|
icon: 'mdiBellOutline',
|
||||||
|
cats: [
|
||||||
|
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
|
||||||
|
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
|
||||||
|
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cmd',
|
||||||
|
labelKey: 'backup.catGroupCmd',
|
||||||
|
icon: 'mdiConsoleLine',
|
||||||
|
cats: [
|
||||||
|
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
|
||||||
|
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
|
||||||
|
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
labelKey: 'backup.catGroupSystem',
|
||||||
|
icon: 'mdiCog',
|
||||||
|
cats: [
|
||||||
|
{ key: 'actions', labelKey: 'backup.catActions' },
|
||||||
|
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleCat(key: string): void {
|
||||||
|
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupState(groupKey: string): 'all' | 'none' | 'some' {
|
||||||
|
const group = categoryGroups.find(g => g.key === groupKey);
|
||||||
|
if (!group) return 'none';
|
||||||
|
const flags = group.cats.map(c => !!selectedCategories[c.key]);
|
||||||
|
if (flags.every(v => v)) return 'all';
|
||||||
|
if (flags.every(v => !v)) return 'none';
|
||||||
|
return 'some';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(groupKey: string): void {
|
||||||
|
const group = categoryGroups.find(g => g.key === groupKey);
|
||||||
|
if (!group) return;
|
||||||
|
const target = groupState(groupKey) !== 'all';
|
||||||
|
const next = { ...selectedCategories };
|
||||||
|
for (const c of group.cats) next[c.key] = target;
|
||||||
|
onCategoriesChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||||
|
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
|
||||||
|
|
||||||
|
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
|
||||||
|
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
|
||||||
|
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
|
||||||
|
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="export-panel glass">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div class="panel-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseExport" size={14} />
|
||||||
|
<span>{t('backup.export')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<!-- Step 1: categories -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<span class="step-label">{t('backup.stepCategories')}</span>
|
||||||
|
<span class="step-count">{totalSelected}</span>
|
||||||
|
</div>
|
||||||
|
<div class="group-grid">
|
||||||
|
{#each categoryGroups as group}
|
||||||
|
{@const state = groupState(group.key)}
|
||||||
|
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
|
||||||
|
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
|
||||||
|
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
|
||||||
|
<span class="group-title">{t(group.labelKey)}</span>
|
||||||
|
<span class="group-state">
|
||||||
|
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
|
||||||
|
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
|
||||||
|
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="chip-row">
|
||||||
|
{#each group.cats as cat}
|
||||||
|
<button class="chip" type="button"
|
||||||
|
class:chip-on={selectedCategories[cat.key]}
|
||||||
|
onclick={() => toggleCat(cat.key)}>
|
||||||
|
{t(cat.labelKey)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: secrets -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<span class="step-label">{t('backup.stepSecrets')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
|
||||||
|
{#each secretsModes as mode}
|
||||||
|
<button type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={exportSecrets === mode.value}
|
||||||
|
class="seg"
|
||||||
|
class:seg-on={exportSecrets === mode.value}
|
||||||
|
onclick={() => onSecretsChange(mode.value)}>
|
||||||
|
<MdiIcon name={mode.icon} size={14} />
|
||||||
|
<span>{t(mode.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if exportSecrets === 'include'}
|
||||||
|
<div class="warn-strip" role="status">
|
||||||
|
<span class="warn-edge" aria-hidden="true"></span>
|
||||||
|
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
|
||||||
|
<span>{t('backup.secretsWarningExport')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: CTA -->
|
||||||
|
<div class="step step-cta">
|
||||||
|
<Button onclick={onExport} disabled={exporting || noneSelected}>
|
||||||
|
{#if exporting}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiDownload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.export-panel {
|
||||||
|
padding: 1.5rem 1.5rem 1.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.panel-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.step-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.group-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
.group {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
padding: 0.55rem 0.65rem 0.7rem;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
|
||||||
|
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.15rem 0.1rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.group-icon { color: var(--color-primary); display: inline-flex; }
|
||||||
|
.group-title {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.group-state {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.group-all .group-state { color: var(--color-primary); }
|
||||||
|
.group-some .group-state { color: var(--color-citrus); }
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
|
||||||
|
.chip-on {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
.seg-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--color-highlight),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-strip {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.warn-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--color-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
entity_counts?: Record<string, number>;
|
||||||
|
warnings?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
created?: number;
|
||||||
|
skipped?: number;
|
||||||
|
overwritten?: number;
|
||||||
|
errors?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
importFile: File | null;
|
||||||
|
importConflict: ConflictMode;
|
||||||
|
validating: boolean;
|
||||||
|
validationResult: ValidationResult | null;
|
||||||
|
importing: boolean;
|
||||||
|
importResult: ImportResult | null;
|
||||||
|
onFileSelect: (file: File | null) => void;
|
||||||
|
onConflictChange: (mode: ConflictMode) => void;
|
||||||
|
onValidate: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
importFile,
|
||||||
|
importConflict,
|
||||||
|
validating,
|
||||||
|
validationResult,
|
||||||
|
importing,
|
||||||
|
importResult,
|
||||||
|
onFileSelect,
|
||||||
|
onConflictChange,
|
||||||
|
onValidate,
|
||||||
|
onImport,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let inputEl = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
|
||||||
|
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
|
||||||
|
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
|
||||||
|
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickFile(): void {
|
||||||
|
inputEl?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event): void {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0] ?? null;
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent): void {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(): void {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityCount = $derived(
|
||||||
|
validationResult?.entity_counts
|
||||||
|
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="import-panel glass">
|
||||||
|
<header class="panel-head">
|
||||||
|
<div class="panel-eyebrow">
|
||||||
|
<MdiIcon name="mdiDatabaseImport" size={14} />
|
||||||
|
<span>{t('backup.import')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="panel-title">{t('backup.importDescription')}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<!-- Step 1: file -->
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<span class="step-label">{t('backup.stepFile')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if importFile}
|
||||||
|
<div class="file-pill">
|
||||||
|
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
|
||||||
|
<div class="file-meta">
|
||||||
|
<div class="file-name" title={importFile.name}>{importFile.name}</div>
|
||||||
|
<div class="file-size">{formatBytes(importFile.size)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="file-change" type="button" onclick={pickFile}>
|
||||||
|
<MdiIcon name="mdiSwapHorizontal" size={14} />
|
||||||
|
<span>{t('backup.changeFile')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button type="button"
|
||||||
|
class="dropzone"
|
||||||
|
class:dropzone-active={dragging}
|
||||||
|
onclick={pickFile}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}>
|
||||||
|
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
|
||||||
|
<span class="dropzone-text">
|
||||||
|
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<input bind:this={inputEl} type="file" accept=".json,application/json"
|
||||||
|
class="visually-hidden" onchange={handleInput} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: validate -->
|
||||||
|
{#if importFile}
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<span class="step-label">{t('backup.stepValidate')}</span>
|
||||||
|
{#if validationResult}
|
||||||
|
<span class="validate-pill"
|
||||||
|
class:validate-ok={validationResult.valid}
|
||||||
|
class:validate-bad={!validationResult.valid}>
|
||||||
|
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
|
||||||
|
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !validationResult}
|
||||||
|
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
|
||||||
|
{#if validating}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
|
||||||
|
{/if}
|
||||||
|
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
|
||||||
|
{#if entityCount > 0}
|
||||||
|
<div class="validate-summary">
|
||||||
|
<span class="validate-count font-mono">{entityCount}</span>
|
||||||
|
<span class="validate-count-label">{t('backup.entities')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="validate-categories">
|
||||||
|
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
|
||||||
|
<span class="validate-cat">
|
||||||
|
<span class="validate-cat-num font-mono">{count}</span>
|
||||||
|
<span class="validate-cat-name">{cat}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if validationResult.warnings?.length}
|
||||||
|
<ul class="validate-list validate-warn">
|
||||||
|
{#each validationResult.warnings as w}
|
||||||
|
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if validationResult.errors?.length}
|
||||||
|
<ul class="validate-list validate-err">
|
||||||
|
{#each validationResult.errors as e}
|
||||||
|
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 3: conflict mode -->
|
||||||
|
{#if importFile && validationResult?.valid}
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-num">03</span>
|
||||||
|
<span class="step-label">{t('backup.stepConflict')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
|
||||||
|
{#each conflictOptions as opt}
|
||||||
|
<button type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={importConflict === opt.value}
|
||||||
|
class="seg"
|
||||||
|
class:seg-on={importConflict === opt.value}
|
||||||
|
onclick={() => onConflictChange(opt.value)}>
|
||||||
|
<MdiIcon name={opt.icon} size={14} />
|
||||||
|
<span>{t(opt.labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step 4: CTA + results -->
|
||||||
|
<div class="step step-cta">
|
||||||
|
{#if importFile && !validationResult?.valid && !validating}
|
||||||
|
<div class="cta-hint">
|
||||||
|
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||||
|
<span>{t('backup.validateFirst')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
|
||||||
|
{#if importing}
|
||||||
|
<MdiIcon name="mdiLoading" size={14} />
|
||||||
|
{:else}
|
||||||
|
<MdiIcon name="mdiUpload" size={14} />
|
||||||
|
{/if}
|
||||||
|
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if importResult}
|
||||||
|
<div class="import-results">
|
||||||
|
<div class="result-tiles">
|
||||||
|
<div class="result-tile tile-created">
|
||||||
|
<span class="result-num font-mono">{importResult.created ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultCreated')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-tile tile-skipped">
|
||||||
|
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultSkipped')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-tile tile-overwritten">
|
||||||
|
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
|
||||||
|
<span class="result-label">{t('backup.resultOverwritten')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if importResult.errors?.length}
|
||||||
|
<ul class="validate-list validate-err">
|
||||||
|
{#each importResult.errors as e}
|
||||||
|
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if importResult.warnings?.length}
|
||||||
|
<ul class="validate-list validate-warn">
|
||||||
|
{#each importResult.warnings as w}
|
||||||
|
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.import-panel {
|
||||||
|
padding: 1.5rem 1.5rem 1.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.panel-head { position: relative; z-index: 1; }
|
||||||
|
.panel-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
max-width: 36ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||||
|
.step-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone */
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 1.65rem 1.1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1.5px dashed var(--color-rule-strong);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
.dropzone:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.dropzone-active {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
|
||||||
|
transform: scale(1.005);
|
||||||
|
}
|
||||||
|
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
|
||||||
|
.dropzone-text { line-height: 1.4; max-width: 28ch; }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
padding: 0; margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File pill */
|
||||||
|
.file-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.file-icon { color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.file-meta { flex: 1; min-width: 0; }
|
||||||
|
.file-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.file-size {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.file-change {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.32rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
|
||||||
|
/* Validation */
|
||||||
|
.validate-pill {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.validate-ok {
|
||||||
|
color: var(--color-success-fg);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
|
||||||
|
}
|
||||||
|
.validate-bad {
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-card {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.validate-card-bad {
|
||||||
|
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
|
||||||
|
}
|
||||||
|
.validate-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.validate-count {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.validate-count-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.validate-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.validate-cat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
.validate-cat-num {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.validate-cat-name {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0; margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.validate-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.validate-warn li { color: var(--color-warning-fg); }
|
||||||
|
.validate-err li { color: var(--color-error-fg); }
|
||||||
|
|
||||||
|
/* Segmented (same vocabulary as ExportPanel) */
|
||||||
|
.segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||||
|
.seg-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 var(--color-highlight),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.result-tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.result-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.result-num {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||||
|
.tile-created .result-num { color: var(--color-mint); }
|
||||||
|
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
|
||||||
|
.tile-skipped .result-num { color: var(--color-sky); }
|
||||||
|
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||||
|
.tile-overwritten .result-num { color: var(--color-citrus); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
|
||||||
|
interface PendingState {
|
||||||
|
pending: boolean;
|
||||||
|
uploaded_at?: string | null;
|
||||||
|
uploaded_by?: string | null;
|
||||||
|
conflict_mode?: string;
|
||||||
|
supervised?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pending: PendingState | null;
|
||||||
|
onApply: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pending, onApply, onCancel }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pending?.pending}
|
||||||
|
<div class="pending-strip animate-rise" role="alert">
|
||||||
|
<span class="pending-edge" aria-hidden="true"></span>
|
||||||
|
<span class="aurora-pulse error" aria-hidden="true"></span>
|
||||||
|
<div class="pending-body">
|
||||||
|
<div class="pending-title">
|
||||||
|
<MdiIcon name="mdiShieldAlertOutline" size={16} />
|
||||||
|
<span>{t('backup.pendingTitle')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pending-meta">
|
||||||
|
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
|
||||||
|
<span class="pending-dot">·</span>
|
||||||
|
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pending-actions">
|
||||||
|
{#if pending.supervised}
|
||||||
|
<Button size="sm" onclick={onApply}>
|
||||||
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<button class="pending-cancel" onclick={onCancel} type="button">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pending-strip {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--color-glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-card),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pending-strip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.pending-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
|
||||||
|
}
|
||||||
|
.pending-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
.pending-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.pending-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin-top: 0.18rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.pending-dot {
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.pending-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pending-cancel {
|
||||||
|
padding: 0 0.95rem;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.pending-cancel:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
|
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: string;
|
||||||
|
secretsMode: string;
|
||||||
|
retentionCount: string;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
enabled,
|
||||||
|
intervalHours = $bindable(),
|
||||||
|
secretsMode = $bindable(),
|
||||||
|
retentionCount = $bindable(),
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
onSave,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const intervalItems: GridItem[] = $derived([
|
||||||
|
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
|
||||||
|
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
|
||||||
|
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
|
||||||
|
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
|
||||||
|
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
|
||||||
|
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const secretsItems: GridItem[] = $derived([
|
||||||
|
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
|
||||||
|
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
|
||||||
|
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const retentionItems: GridItem[] = $derived([
|
||||||
|
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
|
||||||
|
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
|
||||||
|
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
|
||||||
|
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="cassette glass" class:cassette-on={enabled}>
|
||||||
|
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
|
||||||
|
<span class="toggle-track" class:toggle-on={enabled}>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="toggle-label">
|
||||||
|
<span class="cassette-eyebrow">
|
||||||
|
<MdiIcon name="mdiClockOutline" size={12} />
|
||||||
|
<span>{t('backup.scheduled')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="cassette-title">{t('backup.enableScheduled')}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if enabled}
|
||||||
|
<div class="cassette-controls">
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.interval')}</span>
|
||||||
|
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
|
||||||
|
</div>
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.secretsMode')}</span>
|
||||||
|
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
|
||||||
|
</div>
|
||||||
|
<div class="ctl">
|
||||||
|
<span class="ctl-label">{t('backup.retention')}</span>
|
||||||
|
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="cassette-off">{t('backup.scheduleOff')}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="cassette-save">
|
||||||
|
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
|
||||||
|
<MdiIcon name="mdiContentSave" size={14} />
|
||||||
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cassette {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 0.95rem 1.15rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
|
||||||
|
|
||||||
|
.cassette-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.2rem 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-on {
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||||
|
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-thumb {
|
||||||
|
background: white;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.cassette-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.cassette-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-controls {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||||
|
.ctl-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-off {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cassette-save {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cassette-save { width: 100%; }
|
||||||
|
.cassette-save > :global(*) { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { setup } from '$lib/auth.svelte';
|
import { setup } from '$lib/auth.svelte';
|
||||||
|
import { errMsg } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { initTheme } from '$lib/theme.svelte';
|
import { initTheme } from '$lib/theme.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
try {
|
try {
|
||||||
await setup(username, password);
|
await setup(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (err: any) { error = err.message || t('auth.setupFailed'); }
|
} catch (err: unknown) { error = errMsg(err, t('auth.setupFailed')); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
import { chatActionItems } from '$lib/grid-items';
|
import { chatActionItems } from '$lib/grid-items';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
@@ -22,8 +24,9 @@
|
|||||||
|
|
||||||
import TargetForm from './TargetForm.svelte';
|
import TargetForm from './TargetForm.svelte';
|
||||||
import ReceiverSection from './ReceiverSection.svelte';
|
import ReceiverSection from './ReceiverSection.svelte';
|
||||||
|
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ──── Helpers ────
|
||||||
|
|
||||||
function getBotName(target: NotificationTarget): string | null {
|
function getBotName(target: NotificationTarget): string | null {
|
||||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
return recv.receiver_key || '?';
|
return recv.receiver_key || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──
|
// ──── Constants ────
|
||||||
|
|
||||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||||
type TargetType = typeof ALL_TYPES[number];
|
type TargetType = typeof ALL_TYPES[number];
|
||||||
@@ -92,7 +95,54 @@
|
|||||||
label: tt.charAt(0).toUpperCase() + tt.slice(1),
|
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 allTargets = $derived(targetsCache.items);
|
||||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||||
@@ -108,7 +158,7 @@
|
|||||||
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
||||||
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
||||||
|
|
||||||
// ── Target form state ──
|
// ──── Target form state ────
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -129,6 +179,7 @@
|
|||||||
child_target_ids: [] as number[],
|
child_target_ids: [] as number[],
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
@@ -137,12 +188,23 @@
|
|||||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||||
let formEl = $state<HTMLElement | undefined>();
|
let formEl = $state<HTMLElement | undefined>();
|
||||||
|
|
||||||
|
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
|
||||||
|
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
|
||||||
|
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
|
||||||
|
broadcast: 'Broadcast',
|
||||||
|
};
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function scrollToForm() {
|
async function scrollToForm() {
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receiver inline form state ──
|
// ──── Receiver inline form state ────
|
||||||
|
|
||||||
let addingReceiverForTarget = $state<number | null>(null);
|
let addingReceiverForTarget = $state<number | null>(null);
|
||||||
let receiverForm = $state<Record<string, any>>({});
|
let receiverForm = $state<Record<string, any>>({});
|
||||||
@@ -152,7 +214,21 @@
|
|||||||
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
|
||||||
let receiverTesting = $state<Record<number, boolean>>({});
|
let receiverTesting = $state<Record<number, boolean>>({});
|
||||||
|
|
||||||
// ── Effects ──
|
// Per-target expansion state for the receivers section. Hidden by default.
|
||||||
|
let expandedTargets = $state<Set<number>>(new SvelteSet());
|
||||||
|
|
||||||
|
function isExpanded(id: number): boolean {
|
||||||
|
return expandedTargets.has(id);
|
||||||
|
}
|
||||||
|
function toggleExpanded(id: number) {
|
||||||
|
if (expandedTargets.has(id)) expandedTargets.delete(id);
|
||||||
|
else expandedTargets.add(id);
|
||||||
|
}
|
||||||
|
function expandTarget(id: number) {
|
||||||
|
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──── Effects ────
|
||||||
|
|
||||||
// Reset form when switching target type tabs
|
// Reset form when switching target type tabs
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -163,10 +239,102 @@
|
|||||||
addingReceiverForTarget = null;
|
addingReceiverForTarget = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Data loading ──
|
// ──── Data loading ────
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
|
// ──── Bot grouping ────
|
||||||
|
|
||||||
|
type TargetGroup = {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
icon: string;
|
||||||
|
typeBadge: string | null;
|
||||||
|
botHref: string | null;
|
||||||
|
botEntityId: number | null;
|
||||||
|
muted: boolean;
|
||||||
|
targets: NotificationTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOT_TYPES = new Set<string>(['telegram', 'email', 'matrix']);
|
||||||
|
|
||||||
|
const groupedTargets = $derived.by<TargetGroup[]>(() => {
|
||||||
|
const groups = new Map<string, TargetGroup>();
|
||||||
|
for (const tgt of targets) {
|
||||||
|
const isBotType = BOT_TYPES.has(tgt.type);
|
||||||
|
const botId = isBotType ? getBotEntityId(tgt) : null;
|
||||||
|
const key = isBotType
|
||||||
|
? (botId ? `${tgt.type}:${botId}` : `${tgt.type}:nobot`)
|
||||||
|
: `${tgt.type}:direct`;
|
||||||
|
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
const typeBadge = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
let icon = TYPE_ICONS[tgt.type] || 'mdiTarget';
|
||||||
|
let name = '';
|
||||||
|
let subtitle: string | null = null;
|
||||||
|
let muted = false;
|
||||||
|
|
||||||
|
if (isBotType && botId) {
|
||||||
|
if (tgt.type === 'telegram') {
|
||||||
|
const bot = telegramBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.bot_username ? `@${bot.bot_username}` : null;
|
||||||
|
icon = bot?.icon || 'mdiSend';
|
||||||
|
} else if (tgt.type === 'email') {
|
||||||
|
const bot = emailBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.email || null;
|
||||||
|
icon = bot?.icon || 'mdiEmailOutline';
|
||||||
|
} else if (tgt.type === 'matrix') {
|
||||||
|
const bot = matrixBots.find(b => b.id === botId);
|
||||||
|
name = bot?.name || getBotName(tgt) || t('targets.groupBotMissing');
|
||||||
|
subtitle = bot?.display_name || bot?.homeserver_url || null;
|
||||||
|
icon = bot?.icon || 'mdiMatrix';
|
||||||
|
}
|
||||||
|
} else if (isBotType) {
|
||||||
|
name = t('targets.groupNoBot');
|
||||||
|
subtitle = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
muted = true;
|
||||||
|
} else {
|
||||||
|
name = TARGET_TYPE_DEFAULT_NAMES[tgt.type as TargetType] || tgt.type;
|
||||||
|
subtitle = t('targets.groupDirect');
|
||||||
|
muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
group = {
|
||||||
|
key,
|
||||||
|
type: tgt.type,
|
||||||
|
name,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
typeBadge,
|
||||||
|
botHref: isBotType && botId ? getBotHref(tgt) : null,
|
||||||
|
botEntityId: isBotType ? botId : null,
|
||||||
|
muted,
|
||||||
|
targets: [],
|
||||||
|
};
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.targets.push(tgt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (g: TargetGroup) => {
|
||||||
|
if (g.type === 'broadcast') return 4;
|
||||||
|
if (g.muted && BOT_TYPES.has(g.type)) return 2; // bot-type without bot
|
||||||
|
if (g.muted) return 3; // direct delivery (webhook/discord/slack/ntfy)
|
||||||
|
return 1; // bot-linked
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...groups.values()].sort((a, b) => {
|
||||||
|
const ra = rank(a), rb = rank(b);
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const headerPills = $derived.by(() => {
|
const headerPills = $derived.by(() => {
|
||||||
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
|
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
|
||||||
if (activeType) {
|
if (activeType) {
|
||||||
@@ -187,8 +355,8 @@
|
|||||||
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
loadError = err.message || t('common.loadError');
|
loadError = errMsg(err, t('common.loadError'));
|
||||||
snackError(loadError);
|
snackError(loadError);
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
@@ -204,7 +372,17 @@
|
|||||||
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Target CRUD ──
|
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||||
|
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
|
||||||
|
async function discoverReceiverBotChats(botId: number) {
|
||||||
|
if (!botId) return;
|
||||||
|
try {
|
||||||
|
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
|
||||||
|
receiverBotChats = { ...receiverBotChats, [botId]: data };
|
||||||
|
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──── Target CRUD ────
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
@@ -213,6 +391,7 @@
|
|||||||
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
|
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
|
||||||
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
|
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
|
||||||
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
|
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null;
|
editing = null;
|
||||||
showTelegramSettings = false;
|
showTelegramSettings = false;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
@@ -242,6 +421,7 @@
|
|||||||
// broadcast
|
// broadcast
|
||||||
child_target_ids: c.child_target_ids || [],
|
child_target_ids: c.child_target_ids || [],
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = tgt.id;
|
editing = tgt.id;
|
||||||
showTelegramSettings = false;
|
showTelegramSettings = false;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
@@ -295,9 +475,10 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetSaved'));
|
snackSuccess(t('snack.targetSaved'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
@@ -308,7 +489,7 @@
|
|||||||
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(`Failed: ${res.error}`);
|
else snackError(`Failed: ${res.error}`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
@@ -317,25 +498,38 @@
|
|||||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetDeleted'));
|
snackSuccess(t('snack.targetDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
const m = errMsg(err);
|
||||||
snackError(err.message);
|
error = m;
|
||||||
|
snackError(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receiver CRUD ──
|
// ──── Receiver CRUD ────
|
||||||
|
|
||||||
function openReceiverForm(targetId: number, targetType: string) {
|
async function openReceiverForm(targetId: number, targetType: string) {
|
||||||
|
// Force a remount of any picker palette when the same target is reopened
|
||||||
|
// after a prior attempt left addingReceiverForTarget unchanged (e.g. save failure).
|
||||||
|
if (addingReceiverForTarget === targetId) {
|
||||||
|
addingReceiverForTarget = null;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
addingReceiverForTarget = targetId;
|
addingReceiverForTarget = targetId;
|
||||||
|
expandTarget(targetId);
|
||||||
receiverHeadersError = '';
|
receiverHeadersError = '';
|
||||||
if (targetType === 'telegram') {
|
if (targetType === 'telegram') {
|
||||||
receiverForm = { chat_id: '' };
|
receiverForm = { chat_id: '' };
|
||||||
// Load bot chats for the target's bot
|
// Show what we have immediately (cached list), then actively discover in the
|
||||||
|
// background so any newly-added chats appear in the palette as soon as
|
||||||
|
// Telegram returns them.
|
||||||
const tgt = allTargets.find(t => t.id === targetId);
|
const tgt = allTargets.find(t => t.id === targetId);
|
||||||
const botId = tgt?.config?.bot_id;
|
const botId = tgt?.config?.bot_id;
|
||||||
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
|
if (botId) {
|
||||||
|
if (!receiverBotChats[botId]) loadReceiverBotChats(botId);
|
||||||
|
discoverReceiverBotChats(botId);
|
||||||
|
}
|
||||||
} else if (targetType === 'email') {
|
} else if (targetType === 'email') {
|
||||||
receiverForm = { email: '' };
|
receiverForm = { email: '' };
|
||||||
} else if (targetType === 'webhook') {
|
} else if (targetType === 'webhook') {
|
||||||
@@ -383,8 +577,8 @@
|
|||||||
addingReceiverForTarget = null;
|
addingReceiverForTarget = null;
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('targets.receiverAdded'));
|
snackSuccess(t('targets.receiverAdded'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
} finally {
|
} finally {
|
||||||
receiverSubmitting = false;
|
receiverSubmitting = false;
|
||||||
}
|
}
|
||||||
@@ -398,7 +592,7 @@
|
|||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
|
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeReceiver(targetId: number, receiverId: number) {
|
async function removeReceiver(targetId: number, receiverId: number) {
|
||||||
@@ -406,7 +600,7 @@
|
|||||||
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('targets.receiverDeleted'));
|
snackSuccess(t('targets.receiverDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBroadcastChild(targetId: number, childId: number) {
|
async function toggleBroadcastChild(targetId: number, childId: number) {
|
||||||
@@ -421,7 +615,7 @@
|
|||||||
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testReceiver(targetId: number, receiverId: number) {
|
async function testReceiver(targetId: number, receiverId: number) {
|
||||||
@@ -430,7 +624,7 @@
|
|||||||
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
|
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
|
||||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||||
else snackError(`Failed: ${res.error}`);
|
else snackError(`Failed: ${res.error}`);
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: unknown) { snackError(errMsg(err)); }
|
||||||
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -439,7 +633,7 @@
|
|||||||
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
|
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
|
||||||
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
|
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
|
||||||
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
|
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
|
||||||
crumb="Routing · Targets"
|
crumb={t('crumbs.routingTargets')}
|
||||||
count={targets.length}
|
count={targets.length}
|
||||||
countLabel={t('dashboard.targetsShort')}
|
countLabel={t('dashboard.targetsShort')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -476,6 +670,7 @@
|
|||||||
bind:showTelegramSettings
|
bind:showTelegramSettings
|
||||||
onsave={save}
|
onsave={save}
|
||||||
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
|
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
|
||||||
|
onnameinput={() => nameManuallyEdited = true}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -495,53 +690,85 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="targets-list">
|
||||||
{#each targets as target (target.id)}
|
{#each groupedTargets as group (group.key)}
|
||||||
<Card hover entityId={target.id}>
|
<section class="target-group">
|
||||||
<!-- Target header -->
|
<BotGroupHeader
|
||||||
<div class="flex items-center justify-between">
|
icon={group.icon}
|
||||||
<div>
|
name={group.name}
|
||||||
<div class="flex items-center gap-2">
|
subtitle={group.subtitle}
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
targetCount={group.targets.length}
|
||||||
<p class="font-medium">{target.name}</p>
|
typeBadge={!activeType ? group.typeBadge : null}
|
||||||
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
botHref={group.botHref}
|
||||||
{#if target.type === 'broadcast' && target.child_targets?.length}
|
botEntityId={group.botEntityId}
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
|
muted={group.muted}
|
||||||
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
|
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
|
|
||||||
{/if}
|
|
||||||
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
|
||||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Receivers list -->
|
|
||||||
<ReceiverSection
|
|
||||||
{target}
|
|
||||||
typeIcons={TYPE_ICONS}
|
|
||||||
{addingReceiverForTarget}
|
|
||||||
bind:receiverForm
|
|
||||||
{receiverSubmitting}
|
|
||||||
{receiverHeadersError}
|
|
||||||
{receiverBotChats}
|
|
||||||
{receiverTesting}
|
|
||||||
{receiverLabel}
|
|
||||||
onopenReceiverForm={openReceiverForm}
|
|
||||||
onsaveReceiver={saveReceiver}
|
|
||||||
oncancelReceiver={() => addingReceiverForTarget = null}
|
|
||||||
ontoggleReceiver={toggleReceiver}
|
|
||||||
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
|
|
||||||
ontestReceiver={testReceiver}
|
|
||||||
onloadBotChats={loadReceiverBotChats}
|
|
||||||
onchangeReceiverForm={(f) => receiverForm = f}
|
|
||||||
ontoggleBroadcastChild={toggleBroadcastChild}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
<div class="target-group__items stagger-children">
|
||||||
|
{#each group.targets as target (target.id)}
|
||||||
|
{@const expanded = isExpanded(target.id)}
|
||||||
|
{@const childCount = target.type === 'broadcast' ? (target.child_targets?.length || 0) : (target.receivers || []).length}
|
||||||
|
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
|
||||||
|
<Card hover entityId={target.id}>
|
||||||
|
<!-- Target header (clickable to toggle receiver visibility) -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="target-summary"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={`target-body-${target.id}`}
|
||||||
|
onclick={() => toggleExpanded(target.id)}
|
||||||
|
>
|
||||||
|
<span class="target-summary__chevron" class:open={expanded} aria-hidden="true">
|
||||||
|
<MdiIcon name="mdiChevronRight" size={16} />
|
||||||
|
</span>
|
||||||
|
<span class="target-summary__icon"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||||
|
<span class="target-summary__name">{target.name}</span>
|
||||||
|
{#if childCount > 0}
|
||||||
|
<span class="target-summary__count">
|
||||||
|
<span class="target-summary__count-num">{childCount}</span>
|
||||||
|
<span class="target-summary__count-label">{childLabel}</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<MetaStrip tiles={targetTiles(target)} />
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||||
|
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||||
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Receivers list (collapsible) -->
|
||||||
|
{#if expanded}
|
||||||
|
<div id={`target-body-${target.id}`} transition:slide={{ duration: 180 }}>
|
||||||
|
<ReceiverSection
|
||||||
|
{target}
|
||||||
|
typeIcons={TYPE_ICONS}
|
||||||
|
{addingReceiverForTarget}
|
||||||
|
bind:receiverForm
|
||||||
|
{receiverSubmitting}
|
||||||
|
{receiverHeadersError}
|
||||||
|
{receiverBotChats}
|
||||||
|
{receiverTesting}
|
||||||
|
{receiverLabel}
|
||||||
|
onopenReceiverForm={openReceiverForm}
|
||||||
|
onsaveReceiver={saveReceiver}
|
||||||
|
oncancelReceiver={() => addingReceiverForTarget = null}
|
||||||
|
ontoggleReceiver={toggleReceiver}
|
||||||
|
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
|
||||||
|
ontestReceiver={testReceiver}
|
||||||
|
onloadBotChats={loadReceiverBotChats}
|
||||||
|
onchangeReceiverForm={(f) => receiverForm = f}
|
||||||
|
ontoggleBroadcastChild={toggleBroadcastChild}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -563,3 +790,111 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.targets-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.target-group {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.target-group__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
border-left: 1px dashed color-mix(in srgb, var(--color-rule-strong) 70%, transparent);
|
||||||
|
margin-left: 0.55rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.target-group__items {
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-summary {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.1rem 0.25rem 0.1rem 0;
|
||||||
|
margin: -0.1rem 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.target-summary {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.target-summary:hover {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
}
|
||||||
|
.target-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.target-summary__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s ease;
|
||||||
|
}
|
||||||
|
.target-summary__chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.target-summary__icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.target-summary__name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.target-summary__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.target-summary__count-num {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.target-summary__count-label {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.target-summary__count--empty {
|
||||||
|
font-style: italic;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.12rem 0.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
targetCount: number;
|
||||||
|
typeBadge?: string | null;
|
||||||
|
botHref?: string | null;
|
||||||
|
botEntityId?: number | null;
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
name,
|
||||||
|
subtitle = null,
|
||||||
|
targetCount,
|
||||||
|
typeBadge = null,
|
||||||
|
botHref = null,
|
||||||
|
botEntityId = null,
|
||||||
|
muted = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const countLabel = $derived(targetCount === 1 ? t('targets.target') : t('targets.targetsLower'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bot-group-header" class:muted>
|
||||||
|
<div class="bot-avatar">
|
||||||
|
<MdiIcon name={icon} size={18} />
|
||||||
|
</div>
|
||||||
|
<div class="bot-meta">
|
||||||
|
<div class="bot-title-row">
|
||||||
|
<span class="bot-name">{name}</span>
|
||||||
|
{#if typeBadge}
|
||||||
|
<span class="type-badge">{typeBadge}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if subtitle}
|
||||||
|
<span class="bot-sub">{subtitle}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bot-actions">
|
||||||
|
<span class="count-chip">
|
||||||
|
<span class="count-num">{targetCount}</span>
|
||||||
|
<span class="count-label">{countLabel}</span>
|
||||||
|
</span>
|
||||||
|
{#if botHref}
|
||||||
|
<CrossLink href={botHref} icon="mdiArrowTopRight" label={t('targets.openBot')} entityId={botEntityId ?? undefined} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bot-group-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.6rem 0.95rem 0.6rem 0.75rem;
|
||||||
|
margin: 1.4rem 0 0.55rem 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(
|
||||||
|
95deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 14%, var(--color-glass)),
|
||||||
|
var(--color-glass) 75%
|
||||||
|
);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
backdrop-filter: blur(18px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(150%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bot-group-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 12%;
|
||||||
|
bottom: 12%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--color-primary),
|
||||||
|
color-mix(in srgb, var(--color-primary) 35%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.bot-group-header.muted {
|
||||||
|
background: var(--color-glass);
|
||||||
|
}
|
||||||
|
.bot-group-header.muted::before {
|
||||||
|
background: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
.muted .bot-avatar {
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border-color: var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
.bot-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bot-name {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.type-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.bot-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.count-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-glass-strong);
|
||||||
|
border: 1px solid var(--color-rule-strong);
|
||||||
|
}
|
||||||
|
.count-num {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.count-label {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-group-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -114,34 +114,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Inline add-receiver form -->
|
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
|
||||||
{#if addingReceiverForTarget === target.id}
|
{#if target.type === 'telegram'}
|
||||||
|
{@const botId = target.config?.bot_id}
|
||||||
|
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||||
|
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
|
||||||
|
value: c.chat_id,
|
||||||
|
label: c.title || c.username || c.chat_id,
|
||||||
|
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
||||||
|
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
|
||||||
|
disabled: existingKeys.has(c.chat_id),
|
||||||
|
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||||
|
}))}
|
||||||
|
{#if addingReceiverForTarget === target.id}
|
||||||
|
<EntitySelect
|
||||||
|
items={chatItems}
|
||||||
|
bind:value={receiverForm.chat_id}
|
||||||
|
open={true}
|
||||||
|
showTrigger={false}
|
||||||
|
placeholder={t('telegramBot.selectChat')}
|
||||||
|
onselect={(v) => { if (v != null && v !== '') onsaveReceiver(target.id); }}
|
||||||
|
onclose={oncancelReceiver}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={() => onopenReceiverForm(target.id, target.type)}
|
||||||
|
class="mt-1 flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline cursor-pointer">
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
{t('targets.addReceiver')}
|
||||||
|
</button>
|
||||||
|
{:else if addingReceiverForTarget === target.id}
|
||||||
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
<div in:slide={{ duration: 150 }} class="mt-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
|
||||||
{#if target.type === 'telegram'}
|
{#if target.type === 'email'}
|
||||||
{@const botId = target.config?.bot_id}
|
|
||||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
|
||||||
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
|
|
||||||
value: c.chat_id,
|
|
||||||
label: c.title || c.username || c.chat_id,
|
|
||||||
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
|
||||||
desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
|
|
||||||
disabled: existingKeys.has(c.chat_id),
|
|
||||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
|
||||||
}))}
|
|
||||||
{#if chatItems.length > 0}
|
|
||||||
<EntitySelect items={chatItems} bind:value={receiverForm.chat_id} placeholder={t('telegramBot.selectChat')} />
|
|
||||||
{:else}
|
|
||||||
<input bind:value={receiverForm.chat_id} placeholder="Chat ID"
|
|
||||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
||||||
{/if}
|
|
||||||
{#if botId}
|
|
||||||
<button type="button" onclick={() => onloadBotChats(botId)}
|
|
||||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
|
||||||
<MdiIcon name="mdiSync" size={14} />
|
|
||||||
{t('telegramBot.discoverChats')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else if target.type === 'email'}
|
|
||||||
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
|
<input bind:value={receiverForm.email} type="email" placeholder="recipient@example.com"
|
||||||
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
{:else if target.type === 'webhook'}
|
{:else if target.type === 'webhook'}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
showTelegramSettings: boolean;
|
showTelegramSettings: boolean;
|
||||||
onsave: (e: SubmitEvent) => void;
|
onsave: (e: SubmitEvent) => void;
|
||||||
ontoggleTelegramSettings: () => void;
|
ontoggleTelegramSettings: () => void;
|
||||||
|
onnameinput?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
showTelegramSettings = $bindable(),
|
showTelegramSettings = $bindable(),
|
||||||
onsave,
|
onsave,
|
||||||
ontoggleTelegramSettings,
|
ontoggleTelegramSettings,
|
||||||
|
onnameinput,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@
|
|||||||
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="tgt-name" bind:value={form.name} oninput={() => onnameinput?.()} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if formType === 'telegram'}
|
{#if formType === 'telegram'}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
|
import { getDescriptor } from '$lib/providers';
|
||||||
import type { TemplateConfig } from '$lib/types';
|
import type { TemplateConfig } from '$lib/types';
|
||||||
|
|
||||||
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
let allTemplateConfigs = $derived(templateConfigsCache.items);
|
||||||
@@ -194,8 +196,16 @@
|
|||||||
date_only_format: '%d.%m.%Y',
|
date_only_format: '%d.%m.%Y',
|
||||||
});
|
});
|
||||||
let form = $state(defaultForm());
|
let form = $state(defaultForm());
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
let previewTargetType = $state('telegram');
|
let previewTargetType = $state('telegram');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const desc = getDescriptor(form.provider_type);
|
||||||
|
form.name = desc ? `${desc.defaultName} Templates` : 'Templates';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Provider capabilities: from shared cache
|
// Provider capabilities: from shared cache
|
||||||
let allCapabilities = $derived(capabilitiesCache.items);
|
let allCapabilities = $derived(capabilitiesCache.items);
|
||||||
let providerTypes = $derived(Object.keys(allCapabilities));
|
let providerTypes = $derived(Object.keys(allCapabilities));
|
||||||
@@ -251,8 +261,26 @@
|
|||||||
capabilitiesCache.fetch(),
|
capabilitiesCache.fetch(),
|
||||||
supportedLocalesCache.fetch(),
|
supportedLocalesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
|
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
|
||||||
|
// config in edit mode. Mirrors the same hook on tracking-configs so the
|
||||||
|
// Notification Tracker form can link directly to the editor instead of
|
||||||
|
// the generic list. Strips the param afterwards so a browser refresh
|
||||||
|
// doesn't re-open the modal.
|
||||||
|
function _openEditFromUrl() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const editId = params.get('edit');
|
||||||
|
if (!editId) return;
|
||||||
|
const match = allTemplateConfigs.find(c => String(c.id) === editId);
|
||||||
|
if (match) edit(match);
|
||||||
|
params.delete('edit');
|
||||||
|
const qs = params.toString();
|
||||||
|
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||||
|
window.history.replaceState(null, '', cleanUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,6 +319,7 @@
|
|||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
|
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
|
||||||
|
nameManuallyEdited = false;
|
||||||
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||||
refreshDateFormatPreview();
|
refreshDateFormatPreview();
|
||||||
}
|
}
|
||||||
@@ -304,6 +333,7 @@
|
|||||||
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
|
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
|
||||||
date_only_format: c.date_only_format || '%d.%m.%Y',
|
date_only_format: c.date_only_format || '%d.%m.%Y',
|
||||||
};
|
};
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = c.id; showForm = true; activeLocale = primaryLocale;
|
editing = c.id; showForm = true; activeLocale = primaryLocale;
|
||||||
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
||||||
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||||
@@ -317,7 +347,7 @@
|
|||||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||||
showForm = false; editing = null; await load();
|
showForm = false; editing = null; await load();
|
||||||
snackSuccess(t('snack.templateSaved'));
|
snackSuccess(t('snack.templateSaved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -374,8 +404,8 @@
|
|||||||
refreshAllPreviews();
|
refreshAllPreviews();
|
||||||
}
|
}
|
||||||
snackSuccess(t('templateConfig.resetApplied'));
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
snackError(err.message);
|
snackError(errMsg(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,16 +427,55 @@
|
|||||||
setTimeout(() => refreshAllPreviews(), 100);
|
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);
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -418,7 +487,7 @@
|
|||||||
title={t('templateConfig.title')}
|
title={t('templateConfig.title')}
|
||||||
emphasis={t('templateConfig.titleEmphasis')}
|
emphasis={t('templateConfig.titleEmphasis')}
|
||||||
description={t('templateConfig.description')}
|
description={t('templateConfig.description')}
|
||||||
crumb="Routing · Notification"
|
crumb={t('crumbs.routingNotification')}
|
||||||
count={configs.length}
|
count={configs.length}
|
||||||
countLabel={t('templateConfig.countLabel')}
|
countLabel={t('templateConfig.countLabel')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -439,7 +508,7 @@
|
|||||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
|
<input id="tpc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('templateConfig.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -557,7 +626,7 @@
|
|||||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</CollapsibleSlot>
|
</CollapsibleSlot>
|
||||||
@@ -598,24 +667,25 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each configs as config}
|
{#each configs as config}
|
||||||
<Card hover entityId={config.id}>
|
<Card hover entityId={config.id}>
|
||||||
<div class="flex items-start justify-between">
|
<div class="list-row">
|
||||||
<div class="flex-1">
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
|
||||||
<p class="font-medium">{config.name}</p>
|
<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)]">{config.provider_type}</span>
|
<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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if config.description}
|
{#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}
|
{/if}
|
||||||
</div>
|
</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="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail , errMsg} from '$lib/api';
|
||||||
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
import { topbarAction } from '$lib/stores/topbar-action.svelte';
|
||||||
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
|
|
||||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||||
const gridItemSources: Record<string, () => any[]> = {
|
const gridItemSources: Record<string, () => any[]> = {
|
||||||
@@ -157,8 +158,8 @@
|
|||||||
error: res?.error || '',
|
error: res?.error || '',
|
||||||
locale,
|
locale,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
previewModal = { slotName, rendered: '', error: err.message, locale };
|
previewModal = { slotName, rendered: '', error: errMsg(err), locale };
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading = false;
|
previewLoading = false;
|
||||||
}
|
}
|
||||||
@@ -190,6 +191,14 @@
|
|||||||
});
|
});
|
||||||
let form: Record<string, any> = $state(defaultForm());
|
let form: Record<string, any> = $state(defaultForm());
|
||||||
let descriptor = $derived(getDescriptor(form.provider_type));
|
let descriptor = $derived(getDescriptor(form.provider_type));
|
||||||
|
let nameManuallyEdited = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && !nameManuallyEdited && !editing) {
|
||||||
|
const desc = getDescriptor(form.provider_type);
|
||||||
|
form.name = desc ? `${desc.defaultName} Tracking` : 'Tracking';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
topbarAction.set({
|
topbarAction.set({
|
||||||
@@ -208,7 +217,7 @@
|
|||||||
});
|
});
|
||||||
async function load() {
|
async function load() {
|
||||||
try { await trackingConfigsCache.fetch(true); }
|
try { await trackingConfigsCache.fetch(true); }
|
||||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,9 +239,42 @@
|
|||||||
window.history.replaceState(null, '', cleanUrl);
|
window.history.replaceState(null, '', cleanUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
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) {
|
function edit(c: any) {
|
||||||
form = { ...defaultForm(), ...c };
|
form = { ...defaultForm(), ...c };
|
||||||
|
nameManuallyEdited = true;
|
||||||
editing = c.id; showForm = true;
|
editing = c.id; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +285,7 @@
|
|||||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||||
showForm = false; editing = null; await load();
|
showForm = false; editing = null; await load();
|
||||||
snackSuccess(t('snack.trackingConfigSaved'));
|
snackSuccess(t('snack.trackingConfigSaved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let blockedBy = $state<BlockedByDetail | null>(null);
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
@@ -252,10 +294,10 @@
|
|||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||||
catch (err: any) {
|
catch (err: unknown) {
|
||||||
const bb = getBlockedBy(err);
|
const bb = getBlockedBy(err);
|
||||||
if (bb) { blockedBy = bb; return; }
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message; snackError(err.message);
|
const m = errMsg(err); error = m; snackError(m);
|
||||||
}
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
@@ -267,7 +309,7 @@
|
|||||||
title={t('trackingConfig.title')}
|
title={t('trackingConfig.title')}
|
||||||
emphasis={t('trackingConfig.titleEmphasis')}
|
emphasis={t('trackingConfig.titleEmphasis')}
|
||||||
description={t('trackingConfig.description')}
|
description={t('trackingConfig.description')}
|
||||||
crumb="Routing · Notification"
|
crumb={t('crumbs.routingNotification')}
|
||||||
count={configs.length}
|
count={configs.length}
|
||||||
countLabel={t('trackingConfig.countLabel')}
|
countLabel={t('trackingConfig.countLabel')}
|
||||||
pills={headerPills}
|
pills={headerPills}
|
||||||
@@ -288,7 +330,7 @@
|
|||||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||||
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
<input id="tc-name" bind:value={form.name} oninput={() => nameManuallyEdited = true} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,25 +481,26 @@
|
|||||||
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="list-stack stagger-children">
|
||||||
{#each configs as config}
|
{#each configs as config}
|
||||||
{@const desc = getDescriptor(config.provider_type)}
|
{@const desc = getDescriptor(config.provider_type)}
|
||||||
<Card hover entityId={config.id}>
|
<Card hover entityId={config.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="list-row">
|
||||||
<div>
|
<div class="list-row__identity">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
|
<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">{config.name}</p>
|
<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">{config.provider_type}</span>
|
<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>
|
</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(', ')}
|
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,200 +1,225 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api, parseDate } from '$lib/api';
|
import { api, parseDate , errMsg} from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { getAuth } from '$lib/auth.svelte';
|
import { getAuth } from '$lib/auth.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { User } from '$lib/types';
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
|
import type { User } from '$lib/types';
|
||||||
const auth = getAuth();
|
|
||||||
let users = $state<User[]>([]);
|
const auth = getAuth();
|
||||||
let showForm = $state(false);
|
let users = $state<User[]>([]);
|
||||||
let form = $state({ username: '', password: '', role: 'user' });
|
let showForm = $state(false);
|
||||||
let error = $state('');
|
let form = $state({ username: '', password: '', role: 'user' });
|
||||||
let loaded = $state(false);
|
let error = $state('');
|
||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let loaded = $state(false);
|
||||||
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
// Admin reset password
|
|
||||||
let resetUserId = $state<number | null>(null);
|
// Admin reset password
|
||||||
let resetUsername = $state('');
|
let resetUserId = $state<number | null>(null);
|
||||||
let resetPassword = $state('');
|
let resetUsername = $state('');
|
||||||
let resetMsg = $state('');
|
let resetPassword = $state('');
|
||||||
let resetSuccess = $state(false);
|
let resetMsg = $state('');
|
||||||
|
let resetSuccess = $state(false);
|
||||||
// Admin edit username/role
|
|
||||||
let editUserId = $state<number | null>(null);
|
// Admin edit username/role
|
||||||
let editUsername = $state('');
|
let editUserId = $state<number | null>(null);
|
||||||
let editRole = $state('user');
|
let editUsername = $state('');
|
||||||
let editMsg = $state('');
|
let editRole = $state('user');
|
||||||
let editSuccess = $state(false);
|
let editMsg = $state('');
|
||||||
|
let editSuccess = $state(false);
|
||||||
onMount(load);
|
|
||||||
async function load() {
|
onMount(load);
|
||||||
try { users = await api('/users'); }
|
async function load() {
|
||||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
try { users = await api('/users'); }
|
||||||
finally { loaded = true; }
|
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
||||||
}
|
finally { loaded = true; }
|
||||||
|
}
|
||||||
async function create(e: SubmitEvent) {
|
|
||||||
e.preventDefault(); error = '';
|
async function create(e: SubmitEvent) {
|
||||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
e.preventDefault(); error = '';
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
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) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
finally { confirmDelete = null; }
|
||||||
finally { confirmDelete = null; }
|
}
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
}
|
function openResetPassword(user: any) {
|
||||||
function openResetPassword(user: any) {
|
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
}
|
||||||
}
|
function openEditUser(user: any) {
|
||||||
function openEditUser(user: any) {
|
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||||
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
}
|
||||||
}
|
async function saveUserEdit(e: SubmitEvent) {
|
||||||
async function saveUserEdit(e: SubmitEvent) {
|
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||||
e.preventDefault(); editMsg = ''; editSuccess = false;
|
try {
|
||||||
try {
|
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||||
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
editMsg = t('snack.userUpdated');
|
||||||
editMsg = t('snack.userUpdated');
|
editSuccess = true;
|
||||||
editSuccess = true;
|
snackSuccess(editMsg);
|
||||||
snackSuccess(editMsg);
|
await load();
|
||||||
await load();
|
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||||
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
|
||||||
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
}
|
||||||
}
|
async function resetUserPassword(e: SubmitEvent) {
|
||||||
async function resetUserPassword(e: SubmitEvent) {
|
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
try {
|
||||||
try {
|
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
resetMsg = t('common.passwordChanged');
|
||||||
resetMsg = t('common.passwordChanged');
|
resetSuccess = true;
|
||||||
resetSuccess = true;
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
|
||||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
}
|
||||||
}
|
|
||||||
</script>
|
function userTiles(user: User): MetaTile[] {
|
||||||
|
const tiles: MetaTile[] = [];
|
||||||
<PageHeader
|
const isAdmin = user.role === 'admin';
|
||||||
title={t('users.title')}
|
tiles.push({
|
||||||
emphasis={t('users.titleEmphasis')}
|
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
||||||
description={t('users.description')}
|
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
||||||
crumb="System · Access"
|
tone: isAdmin ? 'orchid' : 'sky',
|
||||||
count={users.length}
|
});
|
||||||
countLabel={t('users.countLabel')}
|
tiles.push({
|
||||||
>
|
icon: 'mdiCalendarOutline',
|
||||||
<Button size="sm" onclick={() => showForm = !showForm}>
|
label: parseDate(user.created_at).toLocaleDateString(),
|
||||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
hint: t('users.joined'),
|
||||||
</Button>
|
tone: 'lavender',
|
||||||
</PageHeader>
|
mono: true,
|
||||||
|
});
|
||||||
{#if !loaded}<Loading />{:else}
|
if (user.id === auth.user?.id) {
|
||||||
|
tiles.push({
|
||||||
{#if showForm}
|
icon: 'mdiAccountStar',
|
||||||
<Card class="mb-6">
|
label: t('users.you', 'you'),
|
||||||
{#if error}<ErrorBanner message={error} />{/if}
|
tone: 'mint',
|
||||||
<form onsubmit={create} class="space-y-3">
|
});
|
||||||
<div>
|
}
|
||||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
return tiles;
|
||||||
<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>
|
</script>
|
||||||
<div>
|
|
||||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
<PageHeader
|
||||||
<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)]" />
|
title={t('users.title')}
|
||||||
</div>
|
emphasis={t('users.titleEmphasis')}
|
||||||
<div>
|
description={t('users.description')}
|
||||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
crumb={t('crumbs.systemAccess')}
|
||||||
<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)]">
|
count={users.length}
|
||||||
<option value="user">{t('users.roleUser')}</option>
|
countLabel={t('users.countLabel')}
|
||||||
<option value="admin">{t('users.roleAdmin')}</option>
|
>
|
||||||
</select>
|
<Button size="sm" onclick={() => showForm = !showForm}>
|
||||||
</div>
|
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||||
<Button type="submit">{t('users.create')}</Button>
|
</Button>
|
||||||
</form>
|
</PageHeader>
|
||||||
</Card>
|
|
||||||
{/if}
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
{#if users.length === 0}
|
{#if showForm}
|
||||||
<Card>
|
<Card class="mb-6">
|
||||||
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
{#if error}<ErrorBanner message={error} />{/if}
|
||||||
</Card>
|
<form onsubmit={create} class="space-y-3">
|
||||||
{:else}
|
<div>
|
||||||
<div class="space-y-3 stagger-children">
|
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||||
{#each users as user}
|
<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)]" />
|
||||||
<Card hover>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
||||||
<p class="font-medium">{user.username}</p>
|
<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)]" />
|
||||||
<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>
|
<div>
|
||||||
<div class="flex items-center gap-1">
|
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||||
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
<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)]">
|
||||||
{#if user.id !== auth.user?.id}
|
<option value="user">{t('users.roleUser')}</option>
|
||||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
<option value="admin">{t('users.roleAdmin')}</option>
|
||||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
</select>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
<Button type="submit">{t('users.create')}</Button>
|
||||||
</div>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
{#if users.length === 0}
|
||||||
|
<Card>
|
||||||
{/if}
|
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
||||||
|
</Card>
|
||||||
<!-- Admin reset password modal -->
|
{:else}
|
||||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
<div class="list-stack stagger-children">
|
||||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
{#each users as user}
|
||||||
<div>
|
<Card hover>
|
||||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
<div class="list-row">
|
||||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
<div class="list-row__identity">
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<p class="font-medium truncate">{user.username}</p>
|
||||||
</div>
|
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||||
{#if resetMsg}
|
</div>
|
||||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
<MetaStrip tiles={userTiles(user)} />
|
||||||
{/if}
|
<div class="list-row__actions">
|
||||||
<Button type="submit" class="w-full">
|
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||||
{t('common.save')}
|
{#if user.id !== auth.user?.id}
|
||||||
</Button>
|
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||||
</form>
|
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||||
</Modal>
|
{/if}
|
||||||
|
</div>
|
||||||
<!-- Admin edit username/role modal -->
|
</div>
|
||||||
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
</Card>
|
||||||
<form onsubmit={saveUserEdit} class="space-y-3">
|
{/each}
|
||||||
<div>
|
</div>
|
||||||
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
{/if}
|
||||||
<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)]" />
|
{/if}
|
||||||
</div>
|
|
||||||
<div>
|
<!-- Admin reset password modal -->
|
||||||
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||||
<select id="edit-role" bind:value={editRole}
|
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
<div>
|
||||||
<option value="user">{t('users.roleUser')}</option>
|
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
<option value="admin">{t('users.roleAdmin')}</option>
|
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||||
</select>
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if editMsg}
|
{#if resetMsg}
|
||||||
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
<Button type="submit" class="w-full">
|
||||||
</form>
|
{t('common.save')}
|
||||||
</Modal>
|
</Button>
|
||||||
|
</form>
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
</Modal>
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
|
||||||
|
<!-- 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]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.6.5"
|
version = "0.8.2"
|
||||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ class EventType(str, Enum):
|
|||||||
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
||||||
UPS_OVERLOAD = "ups_overload"
|
UPS_OVERLOAD = "ups_overload"
|
||||||
|
|
||||||
|
# Home Assistant events
|
||||||
|
HA_STATE_CHANGED = "ha_state_changed"
|
||||||
|
HA_AUTOMATION_TRIGGERED = "ha_automation_triggered"
|
||||||
|
HA_SERVICE_CALLED = "ha_service_called"
|
||||||
|
HA_EVENT_FIRED = "ha_event_fired"
|
||||||
|
|
||||||
|
# Bridge self-monitoring events — emitted by the bridge itself when
|
||||||
|
# internal failures cross configured thresholds.
|
||||||
|
BRIDGE_SELF_POLL_FAILURES = "bridge_self_poll_failures"
|
||||||
|
BRIDGE_SELF_DEFERRED_BACKLOG = "bridge_self_deferred_backlog"
|
||||||
|
BRIDGE_SELF_TARGET_FAILURES = "bridge_self_target_failures"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServiceEvent:
|
class ServiceEvent:
|
||||||
|
|||||||
@@ -4,21 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Final
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ..http_base import HttpProviderClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Discord webhook content limit
|
# Discord API constraints (per webhook docs).
|
||||||
MAX_CONTENT_LENGTH = 2000
|
MAX_CONTENT_LENGTH: Final = 2000
|
||||||
|
MAX_USERNAME_LENGTH: Final = 80
|
||||||
|
|
||||||
|
|
||||||
class DiscordClient:
|
class DiscordClient(HttpProviderClient):
|
||||||
"""Sends messages via Discord webhook URLs."""
|
"""Sends messages via Discord webhook URLs."""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||||
self._session = session
|
super().__init__(session, provider_name="discord")
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
@@ -33,6 +36,8 @@ class DiscordClient:
|
|||||||
"""
|
"""
|
||||||
if not webhook_url:
|
if not webhook_url:
|
||||||
return {"success": False, "error": "Missing webhook_url"}
|
return {"success": False, "error": "Missing webhook_url"}
|
||||||
|
if username and len(username) > MAX_USERNAME_LENGTH:
|
||||||
|
return {"success": False, "error": f"username exceeds {MAX_USERNAME_LENGTH} chars"}
|
||||||
|
|
||||||
chunks = _split_message(message, MAX_CONTENT_LENGTH)
|
chunks = _split_message(message, MAX_CONTENT_LENGTH)
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
@@ -42,71 +47,34 @@ class DiscordClient:
|
|||||||
if avatar_url:
|
if avatar_url:
|
||||||
payload["avatar_url"] = avatar_url
|
payload["avatar_url"] = avatar_url
|
||||||
|
|
||||||
result = await self._post(webhook_url, payload)
|
result = await self.request("POST", webhook_url, json=payload)
|
||||||
if not result["success"]:
|
if not result.get("success"):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Small delay between chunks to respect rate limits
|
|
||||||
if len(chunks) > 1:
|
if len(chunks) > 1:
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
_MAX_RETRIES = 3
|
|
||||||
_MAX_RETRY_AFTER = 60.0
|
|
||||||
|
|
||||||
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
|
|
||||||
"""POST with bounded 429 retry.
|
|
||||||
|
|
||||||
We cap retries at _MAX_RETRIES and the ``Retry-After`` header at
|
|
||||||
_MAX_RETRY_AFTER seconds so a hostile or misbehaving upstream cannot
|
|
||||||
pin the dispatch task indefinitely.
|
|
||||||
"""
|
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
|
||||||
try:
|
|
||||||
async with self._session.post(
|
|
||||||
url,
|
|
||||||
json=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
allow_redirects=False,
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 429 and attempt < self._MAX_RETRIES:
|
|
||||||
try:
|
|
||||||
retry_after = float(resp.headers.get("Retry-After", "2"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
retry_after = 2.0
|
|
||||||
retry_after = max(0.0, min(retry_after, self._MAX_RETRY_AFTER))
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Discord rate limited, retrying after %.1fs (attempt %d/%d)",
|
|
||||||
retry_after, attempt + 1, self._MAX_RETRIES,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(retry_after)
|
|
||||||
continue
|
|
||||||
if 200 <= resp.status < 300:
|
|
||||||
return {"success": True}
|
|
||||||
body = await resp.text()
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"HTTP {resp.status}: {body[:200]}",
|
|
||||||
}
|
|
||||||
except aiohttp.ClientError as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
return {"success": False, "error": "Rate limited (retries exhausted)"}
|
|
||||||
|
|
||||||
|
|
||||||
def _split_message(text: str, limit: int) -> list[str]:
|
def _split_message(text: str, limit: int) -> list[str]:
|
||||||
"""Split message into chunks respecting the character limit."""
|
"""Split message into chunks respecting the character limit.
|
||||||
|
|
||||||
|
Drops chunks that contain only whitespace — Discord rejects those.
|
||||||
|
"""
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
return [text]
|
return [text]
|
||||||
chunks = []
|
chunks: list[str] = []
|
||||||
while text:
|
while text:
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
chunks.append(text)
|
piece = text
|
||||||
break
|
text = ""
|
||||||
# Try to split at newline
|
else:
|
||||||
split_at = text.rfind("\n", 0, limit)
|
split_at = text.rfind("\n", 0, limit)
|
||||||
if split_at <= 0:
|
if split_at <= 0:
|
||||||
split_at = limit
|
split_at = limit
|
||||||
chunks.append(text[:split_at])
|
piece = text[:split_at]
|
||||||
text = text[split_at:].lstrip("\n")
|
text = text[split_at:].lstrip("\n")
|
||||||
return chunks
|
if piece.strip():
|
||||||
|
chunks.append(piece)
|
||||||
|
return chunks or [text]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import contextlib
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator, Awaitable, Callable, Final
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -15,37 +15,20 @@ from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
|
|||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
from notify_bridge_core.templates.context import build_template_context
|
from notify_bridge_core.templates.context import build_template_context
|
||||||
from notify_bridge_core.templates.renderer import render_template
|
from notify_bridge_core.templates.renderer import render_template
|
||||||
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
|
||||||
|
|
||||||
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
|
||||||
|
|
||||||
# Cap on how many asset downloads run concurrently inside
|
|
||||||
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
|
||||||
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
|
||||||
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
|
|
||||||
# contains many large videos.
|
|
||||||
_PRELOAD_CONCURRENCY = 6
|
|
||||||
|
|
||||||
|
|
||||||
def _new_session() -> aiohttp.ClientSession:
|
|
||||||
"""Per-dispatch aiohttp session with a sane default timeout.
|
|
||||||
|
|
||||||
We still open a short-lived session per dispatch (connection reuse across
|
|
||||||
dispatches lives in the server-side shared session), but we always attach
|
|
||||||
a total timeout so a hung peer cannot wedge the task forever.
|
|
||||||
"""
|
|
||||||
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
|
|
||||||
|
|
||||||
|
from .http_base import safe_headers
|
||||||
from .receiver import (
|
from .receiver import (
|
||||||
|
DiscordReceiver,
|
||||||
|
EmailReceiver,
|
||||||
|
MatrixReceiver,
|
||||||
|
NtfyReceiver,
|
||||||
Receiver,
|
Receiver,
|
||||||
|
SlackReceiver,
|
||||||
TelegramReceiver,
|
TelegramReceiver,
|
||||||
WebhookReceiver,
|
WebhookReceiver,
|
||||||
EmailReceiver,
|
|
||||||
DiscordReceiver,
|
|
||||||
SlackReceiver,
|
|
||||||
NtfyReceiver,
|
|
||||||
MatrixReceiver,
|
|
||||||
)
|
)
|
||||||
|
from .redact import redact_exc
|
||||||
|
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
||||||
from .telegram.cache import TelegramFileCache
|
from .telegram.cache import TelegramFileCache
|
||||||
from .telegram.client import TelegramClient
|
from .telegram.client import TelegramClient
|
||||||
from .telegram.media import (
|
from .telegram.media import (
|
||||||
@@ -58,7 +41,33 @@ from .webhook.client import WebhookClient
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
|
DEFAULT_TEMPLATE: Final = '{{ event_type }}: "{{ collection_name }}"'
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||||
|
|
||||||
|
# Cap on how many asset downloads run concurrently inside
|
||||||
|
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
||||||
|
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
||||||
|
# max_asset_size``.
|
||||||
|
_PRELOAD_CONCURRENCY: Final = 6
|
||||||
|
|
||||||
|
# Cap on how many targets the dispatcher fans out to at once. With dozens
|
||||||
|
# of targets and a single hung peer, unbounded ``gather`` can pin the
|
||||||
|
# dispatch task. The cap also protects against credential-reuse rate
|
||||||
|
# limits on shared providers.
|
||||||
|
_DISPATCH_CONCURRENCY: Final = 16
|
||||||
|
|
||||||
|
# Cap on parallel per-receiver sends within a single target.
|
||||||
|
_RECEIVER_CONCURRENCY: Final = 8
|
||||||
|
|
||||||
|
# Per-target soft timeout — at the top of the dispatch tree so a single
|
||||||
|
# misbehaving target can't hold the whole batch open. Individual provider
|
||||||
|
# clients carry their own per-request timeouts on top of this.
|
||||||
|
_TARGET_TIMEOUT_S: Final = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def _new_session() -> aiohttp.ClientSession:
|
||||||
|
return aiohttp.ClientSession(timeout=_HTTP_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -66,17 +75,23 @@ class TargetConfig:
|
|||||||
"""Configuration for a notification target."""
|
"""Configuration for a notification target."""
|
||||||
|
|
||||||
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
|
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
|
||||||
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
|
config: dict[str, Any]
|
||||||
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
|
template_slots: dict[str, dict[str, str]] | None = None
|
||||||
locale: str = "en" # default locale for template resolution
|
locale: str = "en"
|
||||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||||
date_only_format: str = "%d.%m.%Y"
|
date_only_format: str = "%d.%m.%Y"
|
||||||
provider_api_key: str | None = None # API key for downloading assets from provider
|
provider_api_key: str | None = None
|
||||||
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
provider_internal_url: str | None = None
|
||||||
provider_external_url: str | None = None # External domain for API key scoping
|
provider_external_url: str | None = None
|
||||||
receivers: list[Receiver] = field(default_factory=list)
|
receivers: list[Receiver] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
_SendMethod = Callable[
|
||||||
|
["NotificationDispatcher", TargetConfig, str, ServiceEvent],
|
||||||
|
Awaitable[dict[str, Any]],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class NotificationDispatcher:
|
class NotificationDispatcher:
|
||||||
"""Dispatches ServiceEvent notifications to configured targets."""
|
"""Dispatches ServiceEvent notifications to configured targets."""
|
||||||
|
|
||||||
@@ -90,18 +105,17 @@ class NotificationDispatcher:
|
|||||||
self._url_cache = url_cache
|
self._url_cache = url_cache
|
||||||
self._asset_cache = asset_cache
|
self._asset_cache = asset_cache
|
||||||
# Optional shared session owned by the caller; when supplied we reuse
|
# Optional shared session owned by the caller; when supplied we reuse
|
||||||
# its connection pool instead of opening a fresh per-dispatch session
|
# its connection pool instead of opening a fresh per-dispatch session.
|
||||||
# (saves a TLS handshake per outbound call).
|
|
||||||
self._shared_session = session
|
self._shared_session = session
|
||||||
|
# Per-dispatch render cache, keyed by locale. Populated by
|
||||||
|
# ``_send_to_target`` and consumed inside ``_message_for_receiver``
|
||||||
|
# so a 100-receiver fan-out renders each unique locale once.
|
||||||
|
# Initialized to empty so handlers called outside the normal
|
||||||
|
# dispatch path (tests) still see a valid dict.
|
||||||
|
self._render_cache: dict[str, str] = {}
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
||||||
"""Yield an aiohttp session, reusing the shared one if provided.
|
|
||||||
|
|
||||||
When a shared session was passed in ``__init__`` we yield it without
|
|
||||||
closing (the caller owns its lifetime). Otherwise we open a
|
|
||||||
short-lived session with our default timeout and close it on exit.
|
|
||||||
"""
|
|
||||||
if self._shared_session is not None and not self._shared_session.closed:
|
if self._shared_session is not None and not self._shared_session.closed:
|
||||||
yield self._shared_session
|
yield self._shared_session
|
||||||
return
|
return
|
||||||
@@ -115,11 +129,9 @@ class NotificationDispatcher:
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Send event notification to all targets.
|
"""Send event notification to all targets.
|
||||||
|
|
||||||
Returns list of results (one per target).
|
Returns one result per target. Per-target failures are isolated;
|
||||||
|
a single bad target cannot poison the batch.
|
||||||
"""
|
"""
|
||||||
# Bind a dispatch_id so every log line emitted by the target sends
|
|
||||||
# (including deep in TelegramClient) can be correlated to the same
|
|
||||||
# upstream event.
|
|
||||||
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
|
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
with bind_log_context(dispatch_id=new_id):
|
with bind_log_context(dispatch_id=new_id):
|
||||||
@@ -128,20 +140,36 @@ class NotificationDispatcher:
|
|||||||
event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
|
event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
|
||||||
getattr(event, "collection_name", None), len(targets),
|
getattr(event, "collection_name", None), len(targets),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(_DISPATCH_CONCURRENCY)
|
||||||
|
|
||||||
|
async def run_one(t: TargetConfig) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
self._send_to_target(event, t),
|
||||||
|
timeout=_TARGET_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Target dispatch timed out after {_TARGET_TIMEOUT_S}s",
|
||||||
|
}
|
||||||
|
|
||||||
raw_results = await asyncio.gather(
|
raw_results = await asyncio.gather(
|
||||||
*[self._send_to_target(event, t) for t in targets],
|
*[run_one(t) for t in targets],
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
results = []
|
results: list[dict[str, Any]] = []
|
||||||
failures = 0
|
failures = 0
|
||||||
for target, raw in zip(targets, raw_results):
|
for target, raw in zip(targets, raw_results):
|
||||||
if isinstance(raw, Exception):
|
if isinstance(raw, Exception):
|
||||||
failures += 1
|
failures += 1
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Dispatch to target type=%s failed: %s",
|
"Dispatch to target type=%s failed: %s",
|
||||||
target.type, raw, exc_info=raw,
|
target.type, redact_exc(raw),
|
||||||
)
|
)
|
||||||
results.append({"success": False, "error": str(raw)})
|
results.append({"success": False, "error": redact_exc(raw)})
|
||||||
else:
|
else:
|
||||||
if isinstance(raw, dict) and not raw.get("success"):
|
if isinstance(raw, dict) and not raw.get("success"):
|
||||||
failures += 1
|
failures += 1
|
||||||
@@ -155,7 +183,6 @@ class NotificationDispatcher:
|
|||||||
def _resolve_template(
|
def _resolve_template(
|
||||||
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve template string for an event, with locale fallback."""
|
|
||||||
template_str = DEFAULT_TEMPLATE
|
template_str = DEFAULT_TEMPLATE
|
||||||
if target.template_slots:
|
if target.template_slots:
|
||||||
locale_map = target.template_slots.get(event.event_type.value)
|
locale_map = target.template_slots.get(event.event_type.value)
|
||||||
@@ -166,7 +193,6 @@ class NotificationDispatcher:
|
|||||||
def _render_message(
|
def _render_message(
|
||||||
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve template and render message for a given locale."""
|
|
||||||
template_str = self._resolve_template(event, target, locale)
|
template_str = self._resolve_template(event, target, locale)
|
||||||
ctx = build_template_context(
|
ctx = build_template_context(
|
||||||
event, target_type=target.type,
|
event, target_type=target.type,
|
||||||
@@ -178,30 +204,53 @@ class NotificationDispatcher:
|
|||||||
def _message_for_receiver(
|
def _message_for_receiver(
|
||||||
self, receiver: Receiver, default_message: str,
|
self, receiver: Receiver, default_message: str,
|
||||||
event: ServiceEvent, target: TargetConfig,
|
event: ServiceEvent, target: TargetConfig,
|
||||||
|
cache: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return per-receiver message, re-rendering if receiver has a different locale."""
|
"""Render message respecting receiver locale, with optional cache.
|
||||||
if receiver.locale and receiver.locale != target.locale:
|
|
||||||
return self._render_message(event, target, receiver.locale)
|
The ``cache`` dict (typically created in ``_send_to_target`` and
|
||||||
return default_message
|
threaded through the per-channel ``_send_*`` handlers) memoizes
|
||||||
|
per-locale renders so a 100-receiver fan-out with two locales
|
||||||
|
renders twice instead of one hundred times.
|
||||||
|
"""
|
||||||
|
loc = receiver.locale or target.locale
|
||||||
|
if loc == target.locale:
|
||||||
|
return default_message
|
||||||
|
if cache is not None:
|
||||||
|
cached = cache.get(loc)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
rendered = self._render_message(event, target, loc)
|
||||||
|
cache[loc] = rendered
|
||||||
|
return rendered
|
||||||
|
return self._render_message(event, target, loc)
|
||||||
|
|
||||||
async def _send_to_target(
|
async def _send_to_target(
|
||||||
self, event: ServiceEvent, target: TargetConfig
|
self, event: ServiceEvent, target: TargetConfig
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send event to a single target (potentially multiple receivers)."""
|
"""Dispatch to a single target via the registered handler.
|
||||||
default_message = self._render_message(event, target, target.locale)
|
|
||||||
|
|
||||||
send_method = {
|
Builds a per-locale render cache once and threads it through the
|
||||||
"telegram": self._send_telegram,
|
send handler. The cache is keyed by receiver locale; the default
|
||||||
"webhook": self._send_webhook,
|
locale's render lives in ``default_message`` and is short-circuited
|
||||||
"email": self._send_email,
|
before any cache lookup.
|
||||||
"discord": self._send_discord,
|
"""
|
||||||
"slack": self._send_slack,
|
default_message = self._render_message(event, target, target.locale)
|
||||||
"ntfy": self._send_ntfy,
|
send_method = _PROVIDER_HANDLERS.get(target.type)
|
||||||
"matrix": self._send_matrix,
|
if send_method is None:
|
||||||
}.get(target.type)
|
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||||
if send_method:
|
# Stash the cache on the dispatcher instance for the duration of
|
||||||
return await send_method(target, default_message, event)
|
# this dispatch — handlers pick it up via _message_for_receiver.
|
||||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
# 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)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _preload_asset_data(
|
async def _preload_asset_data(
|
||||||
self,
|
self,
|
||||||
@@ -210,36 +259,13 @@ class NotificationDispatcher:
|
|||||||
session: aiohttp.ClientSession,
|
session: aiohttp.ClientSession,
|
||||||
max_size: int | None,
|
max_size: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Download each non-cached asset's bytes once and attach to the entry.
|
"""Download each non-cached asset's bytes once, with SSRF guard."""
|
||||||
|
|
||||||
Three benefits:
|
|
||||||
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
|
|
||||||
so we don't fetch each URL twice.
|
|
||||||
* We know the exact upload size, which lets the oversize warning in
|
|
||||||
the rendered text compare against real bytes (for Immich videos,
|
|
||||||
the transcoded ``/video/playback``), not the original ``file_size``.
|
|
||||||
* Assets already in the Telegram file_id cache are skipped, and their
|
|
||||||
stored size (if any) is used to populate ``playback_size`` — so
|
|
||||||
templates see consistent sizes for repeat sends without re-download.
|
|
||||||
|
|
||||||
Entries whose download fails or exceeds ``max_size`` are left without
|
|
||||||
``data``; ``TelegramClient`` will then fall back to its own download
|
|
||||||
path and apply the same checks — no regression, just no preload win.
|
|
||||||
|
|
||||||
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
|
|
||||||
stays predictable: at most N assets worth of bytes held in RAM at
|
|
||||||
once, regardless of ``max_media_to_send``. Total wall-clock is
|
|
||||||
unchanged for small batches and only marginally slower for large
|
|
||||||
ones (most assets fit in a single RTT and SSL negotiation cost
|
|
||||||
dominates, so 6-way parallelism is sufficient).
|
|
||||||
"""
|
|
||||||
if not assets:
|
if not assets:
|
||||||
return
|
return
|
||||||
|
|
||||||
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
|
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
|
||||||
|
|
||||||
async def _fetch(entry: dict[str, Any], media: Any) -> None:
|
async def fetch(entry: dict[str, Any], media: Any) -> None:
|
||||||
# Cache hit → skip download; populate playback_size from stored size.
|
|
||||||
cache, key = self._cache_for_entry(entry)
|
cache, key = self._cache_for_entry(entry)
|
||||||
if cache and key:
|
if cache and key:
|
||||||
cached = cache.get(key)
|
cached = cache.get(key)
|
||||||
@@ -251,28 +277,40 @@ class NotificationDispatcher:
|
|||||||
|
|
||||||
url = entry["url"]
|
url = entry["url"]
|
||||||
headers = entry.get("headers") or {}
|
headers = entry.get("headers") or {}
|
||||||
|
try:
|
||||||
|
# Defense-in-depth: validate even though TelegramClient
|
||||||
|
# also validates. The dispatcher is what triggers the
|
||||||
|
# download, so the guard belongs here too.
|
||||||
|
await avalidate_outbound_url(url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Asset preload skipped: unsafe URL (%s)", redact_exc(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
async with sem:
|
async with sem:
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers) as resp:
|
async with session.get(url, headers=headers) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
return
|
return
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
except aiohttp.ClientError:
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
||||||
return
|
return
|
||||||
if max_size is not None and len(data) > max_size:
|
if max_size is not None and len(data) > max_size:
|
||||||
return
|
return
|
||||||
entry["data"] = data
|
entry["data"] = data
|
||||||
media.extra["playback_size"] = len(data)
|
media.extra["playback_size"] = len(data)
|
||||||
|
|
||||||
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
|
raw = await asyncio.gather(
|
||||||
|
*(fetch(e, m) for e, m in zip(assets, media_assets)),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for r in raw:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
_LOGGER.warning("Asset preload raised: %s", redact_exc(r))
|
||||||
|
|
||||||
def _cache_for_entry(
|
def _cache_for_entry(
|
||||||
self, entry: dict[str, Any],
|
self, entry: dict[str, Any],
|
||||||
) -> tuple[TelegramFileCache | None, str | None]:
|
) -> tuple[TelegramFileCache | None, str | None]:
|
||||||
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
|
|
||||||
|
|
||||||
Returns (None, None) if no cache is configured or no key can be derived.
|
|
||||||
"""
|
|
||||||
cache_key = entry.get("cache_key")
|
cache_key = entry.get("cache_key")
|
||||||
if cache_key:
|
if cache_key:
|
||||||
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
|
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
|
||||||
@@ -287,6 +325,10 @@ class NotificationDispatcher:
|
|||||||
return self._url_cache, url
|
return self._url_cache, url
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Per-provider handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _send_telegram(
|
async def _send_telegram(
|
||||||
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -296,27 +338,25 @@ class NotificationDispatcher:
|
|||||||
max_media = target.config.get("max_media_to_send", 50)
|
max_media = target.config.get("max_media_to_send", 50)
|
||||||
max_group = target.config.get("max_media_per_group", 10)
|
max_group = target.config.get("max_media_per_group", 10)
|
||||||
chunk_delay = target.config.get("media_delay", 500)
|
chunk_delay = target.config.get("media_delay", 500)
|
||||||
max_size = target.config.get("max_asset_size")
|
max_size_mb = target.config.get("max_asset_size")
|
||||||
if max_size:
|
max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb else None
|
||||||
max_size = max_size * 1024 * 1024 # MB to bytes
|
|
||||||
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
|
send_large_as_docs = target.config.get("send_large_photos_as_documents", False)
|
||||||
|
|
||||||
if not bot_token:
|
if not bot_token:
|
||||||
return {"success": False, "error": "Missing bot_token"}
|
return {"success": False, "error": "Missing bot_token"}
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
# Prepare assets list once (shared across receivers)
|
|
||||||
# Prefer internal URL for fetching (LAN speed vs public internet)
|
|
||||||
internal_url = (target.provider_internal_url or "").rstrip("/")
|
internal_url = (target.provider_internal_url or "").rstrip("/")
|
||||||
external_url = (target.provider_external_url or "").rstrip("/")
|
external_url = (target.provider_external_url or "").rstrip("/")
|
||||||
assets = []
|
assets: list[dict[str, Any]] = []
|
||||||
media_assets: list[Any] = [] # aligned with `assets` for preload
|
media_assets: list[Any] = []
|
||||||
for asset in event.added_assets[:max_media]:
|
for asset in event.added_assets[:max_media]:
|
||||||
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
asset_entry = build_telegram_asset_entry(
|
asset_entry = build_telegram_asset_entry(
|
||||||
url=url or "",
|
url=url,
|
||||||
media_type=asset.type.value,
|
media_type=asset.type.value,
|
||||||
api_key=target.provider_api_key,
|
api_key=target.provider_api_key,
|
||||||
internal_url=internal_url,
|
internal_url=internal_url,
|
||||||
@@ -327,26 +367,15 @@ class NotificationDispatcher:
|
|||||||
assets.append(asset_entry)
|
assets.append(asset_entry)
|
||||||
media_assets.append(asset)
|
media_assets.append(asset)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
# Preload all asset bytes once so (a) TelegramClient can skip its
|
await self._preload_asset_data(assets, media_assets, session, max_size_bytes)
|
||||||
# own download and (b) we know exact upload sizes in time for the
|
|
||||||
# oversize warning in the rendered text.
|
|
||||||
await self._preload_asset_data(assets, media_assets, session, max_size)
|
|
||||||
default_message = self._render_message(event, target, target.locale)
|
|
||||||
|
|
||||||
# Asset cache (when in thumbhash mode) invalidates entries when the
|
|
||||||
# asset's visual content changes. The resolver maps asset id → its
|
|
||||||
# current thumbhash. Providers that expose thumbhash put it in
|
|
||||||
# ``asset.extra["thumbhash"]`` (currently Immich).
|
|
||||||
thumbhash_map = {
|
thumbhash_map = {
|
||||||
asset.id: asset.extra.get("thumbhash")
|
asset.id: asset.extra.get("thumbhash")
|
||||||
for asset in event.added_assets
|
for asset in event.added_assets
|
||||||
if asset.extra.get("thumbhash")
|
if asset.extra.get("thumbhash")
|
||||||
}
|
}
|
||||||
thumbhash_resolver = (
|
thumbhash_resolver = thumbhash_map.get if thumbhash_map else None
|
||||||
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
|
||||||
)
|
|
||||||
|
|
||||||
client = TelegramClient(
|
client = TelegramClient(
|
||||||
session, bot_token,
|
session, bot_token,
|
||||||
@@ -355,39 +384,51 @@ class NotificationDispatcher:
|
|||||||
thumbhash_resolver=thumbhash_resolver,
|
thumbhash_resolver=thumbhash_resolver,
|
||||||
)
|
)
|
||||||
|
|
||||||
for receiver in target.receivers:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
||||||
results.append({"success": False, "error": "Invalid telegram receiver"})
|
return {"success": False, "error": "Invalid telegram receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
|
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
|
||||||
|
|
||||||
text_result = await client.send_message(
|
text_result = await client.send_message(
|
||||||
chat_id=receiver.chat_id,
|
chat_id=receiver.chat_id,
|
||||||
text=message,
|
text=message,
|
||||||
disable_web_page_preview=bool(disable_preview),
|
disable_web_page_preview=bool(disable_preview),
|
||||||
)
|
)
|
||||||
if not text_result.get("success"):
|
if not text_result.get("success"):
|
||||||
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error"))
|
_LOGGER.warning(
|
||||||
results.append(text_result)
|
"Failed to send to chat %s: %s",
|
||||||
continue
|
receiver.chat_id, text_result.get("error"),
|
||||||
|
)
|
||||||
|
return text_result
|
||||||
|
|
||||||
if assets:
|
if assets:
|
||||||
reply_to = text_result.get("message_id")
|
|
||||||
media_result = await client.send_notification(
|
media_result = await client.send_notification(
|
||||||
chat_id=receiver.chat_id,
|
chat_id=receiver.chat_id,
|
||||||
assets=assets,
|
assets=assets,
|
||||||
reply_to_message_id=reply_to,
|
reply_to_message_id=text_result.get("message_id"),
|
||||||
max_group_size=max_group,
|
max_group_size=max_group,
|
||||||
chunk_delay=chunk_delay,
|
chunk_delay=chunk_delay,
|
||||||
max_asset_data_size=max_size,
|
max_asset_data_size=max_size_bytes,
|
||||||
send_large_photos_as_documents=send_large_as_docs,
|
send_large_photos_as_documents=send_large_as_docs,
|
||||||
chat_action=chat_action or None,
|
chat_action=chat_action or None,
|
||||||
)
|
)
|
||||||
if not media_result.get("success"):
|
if not media_result.get("success"):
|
||||||
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error"))
|
_LOGGER.warning(
|
||||||
|
"Text sent OK but media failed for chat %s: %s",
|
||||||
|
receiver.chat_id, media_result.get("error"),
|
||||||
|
)
|
||||||
|
# Preserve both outcomes — text succeeded, media
|
||||||
|
# didn't. Operators losing media-failure detail
|
||||||
|
# in the result dict made root-cause analysis
|
||||||
|
# impossible.
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": text_result.get("message_id"),
|
||||||
|
"media_error": media_result.get("error"),
|
||||||
|
"media_failed_at_chunk": media_result.get("failed_at_chunk"),
|
||||||
|
}
|
||||||
|
return text_result
|
||||||
|
|
||||||
results.append(text_result)
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
@@ -397,18 +438,11 @@ class NotificationDispatcher:
|
|||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
for receiver in target.receivers:
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||||
results.append({"success": False, "error": "Invalid webhook receiver"})
|
return {"success": False, "error": "Invalid webhook receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
try:
|
|
||||||
await avalidate_outbound_url(receiver.url)
|
|
||||||
except UnsafeURLError as err:
|
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
|
||||||
continue
|
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
|
||||||
payload = {
|
payload = {
|
||||||
"message": message,
|
"message": message,
|
||||||
"event_type": event.event_type.value,
|
"event_type": event.event_type.value,
|
||||||
@@ -417,8 +451,10 @@ class NotificationDispatcher:
|
|||||||
"collection_id": event.collection_id,
|
"collection_id": event.collection_id,
|
||||||
"timestamp": event.timestamp.isoformat(),
|
"timestamp": event.timestamp.isoformat(),
|
||||||
}
|
}
|
||||||
client = WebhookClient(session, receiver.url, receiver.headers)
|
client = WebhookClient(session, receiver.url, safe_headers(receiver.headers))
|
||||||
results.append(await client.send(payload))
|
return await client.send(payload)
|
||||||
|
|
||||||
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
@@ -431,7 +467,7 @@ class NotificationDispatcher:
|
|||||||
if not smtp_cfg.get("host"):
|
if not smtp_cfg.get("host"):
|
||||||
return {"success": False, "error": "SMTP not configured"}
|
return {"success": False, "error": "SMTP not configured"}
|
||||||
|
|
||||||
client = EmailClient(SmtpConfig(
|
email_client = EmailClient(SmtpConfig(
|
||||||
host=smtp_cfg["host"],
|
host=smtp_cfg["host"],
|
||||||
port=int(smtp_cfg.get("port", 587)),
|
port=int(smtp_cfg.get("port", 587)),
|
||||||
username=smtp_cfg.get("username", ""),
|
username=smtp_cfg.get("username", ""),
|
||||||
@@ -439,27 +475,28 @@ class NotificationDispatcher:
|
|||||||
from_address=smtp_cfg.get("from_address", ""),
|
from_address=smtp_cfg.get("from_address", ""),
|
||||||
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
|
from_name=smtp_cfg.get("from_name", "Notify Bridge"),
|
||||||
use_tls=smtp_cfg.get("use_tls", True),
|
use_tls=smtp_cfg.get("use_tls", True),
|
||||||
|
tls_mode=smtp_cfg.get("tls_mode", "auto"),
|
||||||
))
|
))
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
|
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
for receiver in target.receivers:
|
|
||||||
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
||||||
results.append({"success": False, "error": "Invalid email receiver"})
|
return {"success": False, "error": "Invalid email receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
# body_html=None lets EmailClient build a safely-escaped HTML
|
||||||
result = await client.send(
|
# alternative from body_text instead of trusting user content.
|
||||||
|
return await email_client.send(
|
||||||
to_email=receiver.email,
|
to_email=receiver.email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=message,
|
body_text=message,
|
||||||
body_html=message,
|
body_html=None,
|
||||||
to_name=receiver.name,
|
to_name=receiver.name,
|
||||||
)
|
)
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_discord(
|
async def _send_discord(
|
||||||
@@ -471,20 +508,16 @@ class NotificationDispatcher:
|
|||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
client = DiscordClient(session)
|
client = DiscordClient(session)
|
||||||
for receiver in target.receivers:
|
|
||||||
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid discord receiver"})
|
return {"success": False, "error": "Invalid discord receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
try:
|
return await client.send(receiver.webhook_url, message, username=username)
|
||||||
await avalidate_outbound_url(receiver.webhook_url)
|
|
||||||
except UnsafeURLError as err:
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
|
||||||
continue
|
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
|
||||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
@@ -497,20 +530,16 @@ class NotificationDispatcher:
|
|||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
client = SlackClient(session)
|
client = SlackClient(session)
|
||||||
for receiver in target.receivers:
|
|
||||||
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid slack receiver"})
|
return {"success": False, "error": "Invalid slack receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
try:
|
return await client.send(receiver.webhook_url, message, username=username)
|
||||||
await avalidate_outbound_url(receiver.webhook_url)
|
|
||||||
except UnsafeURLError as err:
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
|
||||||
continue
|
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
|
||||||
results.append(await client.send(receiver.webhook_url, message, username=username))
|
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
@@ -526,22 +555,23 @@ class NotificationDispatcher:
|
|||||||
try:
|
try:
|
||||||
await avalidate_outbound_url(server_url)
|
await avalidate_outbound_url(server_url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
|
return {"success": False, "error": f"Unsafe ntfy server_url: {redact_exc(err)}"}
|
||||||
|
|
||||||
title = f"{event.event_type.value}: {event.collection_name}"
|
title = f"{event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
client = NtfyClient(session)
|
client = NtfyClient(session)
|
||||||
for receiver in target.receivers:
|
|
||||||
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||||
results.append({"success": False, "error": "Invalid ntfy receiver"})
|
return {"success": False, "error": "Invalid ntfy receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
return await client.send(
|
||||||
results.append(await client.send(
|
|
||||||
server_url, receiver.topic, message,
|
server_url, receiver.topic, message,
|
||||||
title=title, priority=receiver.priority, auth_token=auth_token,
|
title=title, priority=receiver.priority, auth_token=auth_token,
|
||||||
))
|
)
|
||||||
|
|
||||||
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
@@ -557,33 +587,108 @@ class NotificationDispatcher:
|
|||||||
try:
|
try:
|
||||||
await avalidate_outbound_url(homeserver)
|
await avalidate_outbound_url(homeserver)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
|
return {"success": False, "error": f"Unsafe matrix homeserver_url: {redact_exc(err)}"}
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with self._session_ctx() as session:
|
async with self._session_ctx() as session:
|
||||||
client = MatrixClient(session, homeserver, access_token)
|
client = MatrixClient(session, homeserver, access_token)
|
||||||
for receiver in target.receivers:
|
|
||||||
|
async def send_one(receiver: Receiver) -> dict[str, Any]:
|
||||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||||
results.append({"success": False, "error": "Invalid matrix receiver"})
|
return {"success": False, "error": "Invalid matrix receiver"}
|
||||||
continue
|
message = self._message_for_receiver(receiver, default_message, event, target, cache=self._render_cache)
|
||||||
message = self._message_for_receiver(receiver, default_message, event, target)
|
# body_html is the same plain text — Matrix accepts the
|
||||||
results.append(await client.send_message(
|
# raw message as both ``body`` and ``formatted_body``.
|
||||||
receiver.room_id, message, html_message=message,
|
# If templates emit HTML in the future, generate a
|
||||||
))
|
# separate HTML body upstream rather than aliasing here.
|
||||||
|
return await client.send_message(
|
||||||
|
receiver.room_id, message, html_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await self._fan_out(target.receivers, send_one)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Aggregation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _fan_out(
|
||||||
|
receivers: list[Receiver],
|
||||||
|
send_one: Callable[[Receiver], Awaitable[dict[str, Any]]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Run ``send_one`` per receiver with bounded concurrency.
|
||||||
|
|
||||||
|
Per-receiver exceptions are converted to failure dicts so a single
|
||||||
|
bad receiver can't cancel its peers.
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(_RECEIVER_CONCURRENCY)
|
||||||
|
|
||||||
|
async def guarded(receiver: Receiver) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
return await send_one(receiver)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_LOGGER.error("Receiver send raised: %s", redact_exc(exc))
|
||||||
|
return {"success": False, "error": redact_exc(exc)}
|
||||||
|
|
||||||
|
return await asyncio.gather(*(guarded(r) for r in receivers))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""Aggregate broadcast results into a single result dict."""
|
"""Aggregate per-receiver results into a single target-level result.
|
||||||
|
|
||||||
|
Preserves the per-receiver detail under ``receivers`` so a caller
|
||||||
|
can see exactly which receivers failed, instead of getting only
|
||||||
|
the first error.
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
successes = sum(1 for r in results if r.get("success"))
|
successes = sum(1 for r in results if r.get("success"))
|
||||||
if successes == len(results) and results:
|
failures = len(results) - successes
|
||||||
return {"success": True, "receivers": len(results)}
|
out: dict[str, Any] = {
|
||||||
elif successes > 0:
|
"success": successes > 0,
|
||||||
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
|
"receivers": len(results),
|
||||||
elif results:
|
"successes": successes,
|
||||||
return results[0]
|
"failures": failures,
|
||||||
return {"success": False, "error": "No receivers configured"}
|
"results": results,
|
||||||
|
}
|
||||||
|
if failures:
|
||||||
|
out["errors"] = [
|
||||||
|
r.get("error") for r in results if not r.get("success")
|
||||||
|
]
|
||||||
|
if successes == 0:
|
||||||
|
# Surface the first error at the top level for back-compat
|
||||||
|
# with callers that only check ``error``.
|
||||||
|
out["error"] = results[0].get("error", "All receivers failed")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Provider registry — replaces the if/elif chain so adding a provider
|
||||||
|
# means just registering it here, not editing dispatch logic.
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PROVIDER_HANDLERS: dict[str, _SendMethod] = {
|
||||||
|
"telegram": NotificationDispatcher._send_telegram,
|
||||||
|
"webhook": NotificationDispatcher._send_webhook,
|
||||||
|
"email": NotificationDispatcher._send_email,
|
||||||
|
"discord": NotificationDispatcher._send_discord,
|
||||||
|
"slack": NotificationDispatcher._send_slack,
|
||||||
|
"ntfy": NotificationDispatcher._send_ntfy,
|
||||||
|
"matrix": NotificationDispatcher._send_matrix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider(name: str, handler: _SendMethod) -> None:
|
||||||
|
"""Register a new dispatcher provider at runtime.
|
||||||
|
|
||||||
|
Allows out-of-tree providers to extend the dispatcher without
|
||||||
|
forking. The handler must follow the
|
||||||
|
``async (dispatcher, target, default_message, event) -> dict`` shape.
|
||||||
|
"""
|
||||||
|
_PROVIDER_HANDLERS[name] = handler
|
||||||
|
|||||||
@@ -2,14 +2,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.headerregistry import Address
|
||||||
from email.mime.text import MIMEText
|
from email.message import EmailMessage
|
||||||
from typing import Any
|
from typing import Any, Final, Literal
|
||||||
|
|
||||||
|
try: # Optional dependency — fail at first send rather than at import.
|
||||||
|
import aiosmtplib
|
||||||
|
from aiosmtplib import SMTPException
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
aiosmtplib = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
class SMTPException(Exception): # type: ignore[no-redef]
|
||||||
|
pass
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT_S: Final = 30.0
|
||||||
|
_TlsMode = Literal["auto", "implicit", "starttls", "none"]
|
||||||
|
# RFC 5322 lite: catches the obvious-bad addresses ("foo bar", "no-at",
|
||||||
|
# embedded CRLF) without pretending to fully validate addresses.
|
||||||
|
_EMAIL_RE: Final = re.compile(r"^[^\s@\r\n,;<>]+@[^\s@\r\n,;<>]+\.[^\s@\r\n,;<>]+$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SmtpConfig:
|
class SmtpConfig:
|
||||||
@@ -22,6 +40,55 @@ class SmtpConfig:
|
|||||||
from_address: str = ""
|
from_address: str = ""
|
||||||
from_name: str = "Notify Bridge"
|
from_name: str = "Notify Bridge"
|
||||||
use_tls: bool = True
|
use_tls: bool = True
|
||||||
|
# Explicit TLS mode. ``auto`` (back-compat) infers from ``use_tls`` and
|
||||||
|
# ``port``: 465 → implicit; 587 with use_tls=False → starttls; 25 → none.
|
||||||
|
tls_mode: _TlsMode = "auto"
|
||||||
|
timeout_s: float = _DEFAULT_TIMEOUT_S
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_header(value: str) -> str:
|
||||||
|
"""Reject CRLF and bare CR/LF in header-bound strings.
|
||||||
|
|
||||||
|
SMTP header injection turns user-controlled subject/name strings into
|
||||||
|
arbitrary headers (``\\r\\nBcc: attacker@x``). The Python stdlib
|
||||||
|
accepts CRLF when followed by SP/HT (header folding), so explicit
|
||||||
|
sanitization is required even though :class:`EmailMessage` does some
|
||||||
|
validation of its own.
|
||||||
|
"""
|
||||||
|
return re.sub(r"[\r\n]+", " ", str(value or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_email(addr: str) -> str:
|
||||||
|
addr = _strip_header(addr)
|
||||||
|
if not addr:
|
||||||
|
raise ValueError("email address is empty")
|
||||||
|
if not _EMAIL_RE.match(addr):
|
||||||
|
raise ValueError("email address is invalid")
|
||||||
|
return addr
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tls(cfg: SmtpConfig) -> tuple[bool, bool]:
|
||||||
|
"""Resolve ``(use_tls, start_tls)`` flags from the config.
|
||||||
|
|
||||||
|
``tls_mode`` overrides ``use_tls``/port heuristics when provided.
|
||||||
|
"""
|
||||||
|
mode = cfg.tls_mode
|
||||||
|
if mode == "implicit":
|
||||||
|
return True, False
|
||||||
|
if mode == "starttls":
|
||||||
|
return False, True
|
||||||
|
if mode == "none":
|
||||||
|
return False, False
|
||||||
|
# auto — preserve the historical "use_tls bool + port heuristic" behavior
|
||||||
|
# but make the path explicit.
|
||||||
|
if cfg.use_tls:
|
||||||
|
return True, False
|
||||||
|
return False, cfg.port != 25
|
||||||
|
|
||||||
|
|
||||||
|
def _to_html(text: str) -> str:
|
||||||
|
"""Convert plain text to a minimal HTML body, escaped for safety."""
|
||||||
|
return "<html><body><pre>" + html.escape(text) + "</pre></body></html>"
|
||||||
|
|
||||||
|
|
||||||
class EmailClient:
|
class EmailClient:
|
||||||
@@ -30,30 +97,39 @@ class EmailClient:
|
|||||||
def __init__(self, smtp_config: SmtpConfig) -> None:
|
def __init__(self, smtp_config: SmtpConfig) -> None:
|
||||||
self._config = smtp_config
|
self._config = smtp_config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ssl_context() -> ssl.SSLContext:
|
||||||
|
# Explicit context so the TLS posture is auditable; aiosmtplib
|
||||||
|
# defaults look correct today but past regressions (and downstream
|
||||||
|
# repackaging) make implicit reliance fragile.
|
||||||
|
return ssl.create_default_context()
|
||||||
|
|
||||||
async def verify_connection(self) -> dict[str, Any]:
|
async def verify_connection(self) -> dict[str, Any]:
|
||||||
"""Test SMTP connection and authentication without sending an email."""
|
"""Test SMTP connection and authentication without sending an email."""
|
||||||
try:
|
if aiosmtplib is None:
|
||||||
import aiosmtplib
|
|
||||||
except ImportError:
|
|
||||||
return {"success": False, "error": "aiosmtplib not installed"}
|
return {"success": False, "error": "aiosmtplib not installed"}
|
||||||
|
|
||||||
cfg = self._config
|
cfg = self._config
|
||||||
if not cfg.host:
|
if not cfg.host:
|
||||||
return {"success": False, "error": "SMTP host not configured"}
|
return {"success": False, "error": "SMTP host not configured"}
|
||||||
|
|
||||||
|
use_tls, start_tls = _resolve_tls(cfg)
|
||||||
try:
|
try:
|
||||||
smtp = aiosmtplib.SMTP(
|
smtp = aiosmtplib.SMTP(
|
||||||
hostname=cfg.host,
|
hostname=cfg.host,
|
||||||
port=cfg.port,
|
port=cfg.port,
|
||||||
use_tls=cfg.use_tls,
|
use_tls=use_tls,
|
||||||
start_tls=not cfg.use_tls and cfg.port != 25,
|
start_tls=start_tls,
|
||||||
|
tls_context=self._ssl_context(),
|
||||||
|
timeout=cfg.timeout_s,
|
||||||
|
validate_certs=True,
|
||||||
)
|
)
|
||||||
await smtp.connect()
|
await smtp.connect()
|
||||||
if cfg.username and cfg.password:
|
if cfg.username and cfg.password:
|
||||||
await smtp.login(cfg.username, cfg.password)
|
await smtp.login(cfg.username, cfg.password)
|
||||||
await smtp.quit()
|
await smtp.quit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except (SMTPException, OSError) as e:
|
||||||
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
|
_LOGGER.warning("SMTP verification failed for %s:%d: %s", cfg.host, cfg.port, e)
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@@ -65,27 +141,52 @@ class EmailClient:
|
|||||||
body_html: str | None = None,
|
body_html: str | None = None,
|
||||||
to_name: str = "",
|
to_name: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send an email. Returns {"success": True} or {"success": False, "error": "..."}."""
|
"""Send an email.
|
||||||
try:
|
|
||||||
import aiosmtplib
|
Returns ``{"success": True}`` or ``{"success": False, "error": "..."}``.
|
||||||
except ImportError:
|
|
||||||
|
``body_html`` is treated as already-safe markup. Pass ``None`` to
|
||||||
|
derive a safe HTML alternative from ``body_text`` automatically.
|
||||||
|
"""
|
||||||
|
if aiosmtplib is None:
|
||||||
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
|
return {"success": False, "error": "aiosmtplib not installed. Run: pip install aiosmtplib"}
|
||||||
|
|
||||||
cfg = self._config
|
cfg = self._config
|
||||||
|
|
||||||
if not cfg.host or not cfg.from_address:
|
if not cfg.host or not cfg.from_address:
|
||||||
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
|
return {"success": False, "error": "SMTP not configured (missing host or from_address)"}
|
||||||
|
|
||||||
# Build email message
|
try:
|
||||||
msg = MIMEMultipart("alternative")
|
to_addr = _validate_email(to_email)
|
||||||
msg["From"] = f"{cfg.from_name} <{cfg.from_address}>" if cfg.from_name else cfg.from_address
|
from_addr = _validate_email(cfg.from_address)
|
||||||
msg["To"] = f"{to_name} <{to_email}>" if to_name else to_email
|
except ValueError as exc:
|
||||||
msg["Subject"] = subject
|
return {"success": False, "error": f"Invalid email address: {exc}"}
|
||||||
|
|
||||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
# EmailMessage with structured Address objects rejects CRLF and
|
||||||
if body_html:
|
# framework-folds long headers safely. We still strip first because
|
||||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
# EmailMessage's display-name slot is a pure string.
|
||||||
|
msg = EmailMessage()
|
||||||
|
from_display = _strip_header(cfg.from_name) or ""
|
||||||
|
to_display = _strip_header(to_name) or ""
|
||||||
|
try:
|
||||||
|
from_user, _, from_domain = from_addr.partition("@")
|
||||||
|
to_user, _, to_domain = to_addr.partition("@")
|
||||||
|
msg["From"] = Address(from_display, from_user, from_domain) if from_display else from_addr
|
||||||
|
msg["To"] = Address(to_display, to_user, to_domain) if to_display else to_addr
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "error": f"Invalid email address: {exc}"}
|
||||||
|
msg["Subject"] = _strip_header(subject)
|
||||||
|
|
||||||
|
msg.set_content(body_text or "", subtype="plain", charset="utf-8")
|
||||||
|
# If the caller provided HTML explicitly, honor it; otherwise build a
|
||||||
|
# safe escaped version so a stray "<" in the rendered template can't
|
||||||
|
# break the markup.
|
||||||
|
msg.add_alternative(
|
||||||
|
body_html if body_html is not None else _to_html(body_text or ""),
|
||||||
|
subtype="html",
|
||||||
|
charset="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
use_tls, start_tls = _resolve_tls(cfg)
|
||||||
try:
|
try:
|
||||||
await aiosmtplib.send(
|
await aiosmtplib.send(
|
||||||
msg,
|
msg,
|
||||||
@@ -93,11 +194,14 @@ class EmailClient:
|
|||||||
port=cfg.port,
|
port=cfg.port,
|
||||||
username=cfg.username or None,
|
username=cfg.username or None,
|
||||||
password=cfg.password or None,
|
password=cfg.password or None,
|
||||||
use_tls=cfg.use_tls,
|
use_tls=use_tls,
|
||||||
start_tls=not cfg.use_tls and cfg.port != 25,
|
start_tls=start_tls,
|
||||||
|
tls_context=self._ssl_context(),
|
||||||
|
timeout=cfg.timeout_s,
|
||||||
|
validate_certs=True,
|
||||||
)
|
)
|
||||||
_LOGGER.info("Email sent to %s", to_email)
|
_LOGGER.info("Email sent to %s", to_addr)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except (SMTPException, OSError) as e:
|
||||||
_LOGGER.error("Failed to send email to %s: %s", to_email, e)
|
_LOGGER.error("Failed to send email to %s: %s", to_addr, e)
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Shared HTTP infrastructure for notification provider clients.
|
||||||
|
|
||||||
|
Slack/Discord/ntfy/Matrix/Webhook all follow the same pattern: build a
|
||||||
|
JSON payload, POST/PUT it, decode 200-range as success, decode 4xx/5xx
|
||||||
|
into a stable error dict, and retry transient 429/503 responses with a
|
||||||
|
capped ``Retry-After``. ``HttpProviderClient`` centralizes that pattern
|
||||||
|
so every provider gets the same SSRF guard, timeouts, secret-redacted
|
||||||
|
errors, and bounded retry policy by construction — adding a new
|
||||||
|
provider doesn't get to forget any one of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Final, Mapping
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .redact import redact, redact_exc
|
||||||
|
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||||
|
_MAX_RETRIES: Final = 3
|
||||||
|
_MAX_RETRY_AFTER_S: Final = 60.0
|
||||||
|
_RETRY_STATUSES: Final = frozenset({429, 503})
|
||||||
|
|
||||||
|
# Hop-by-hop / framing headers a caller must not be able to override via
|
||||||
|
# user-supplied target config. Letting them through enables request
|
||||||
|
# smuggling, host-header bypasses of WAFs, and cache poisoning.
|
||||||
|
_FORBIDDEN_HEADERS: Final = frozenset({
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
"transfer-encoding",
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"te",
|
||||||
|
"upgrade",
|
||||||
|
"expect",
|
||||||
|
"proxy-authorization",
|
||||||
|
"proxy-connection",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
||||||
|
"""Return a copy of ``headers`` with hop-by-hop/forbidden names dropped
|
||||||
|
and CRLF-bearing values rejected.
|
||||||
|
|
||||||
|
A target config that lets a user inject ``"X-Foo": "bar\\r\\nHost: evil"``
|
||||||
|
can perform request smuggling depending on aiohttp's framing. We strip
|
||||||
|
those values rather than letting them reach the wire.
|
||||||
|
"""
|
||||||
|
if not headers:
|
||||||
|
return {}
|
||||||
|
safe: dict[str, str] = {}
|
||||||
|
for raw_name, raw_value in headers.items():
|
||||||
|
name = str(raw_name).strip()
|
||||||
|
if not name or name.lower() in _FORBIDDEN_HEADERS:
|
||||||
|
continue
|
||||||
|
if any(c in name for c in "\r\n:"):
|
||||||
|
continue
|
||||||
|
value = str(raw_value)
|
||||||
|
if "\r" in value or "\n" in value:
|
||||||
|
continue
|
||||||
|
safe[name] = value
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def make_error(message: str, *, status: int | None = None, body: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Build a stable failure dict shape used by every provider client."""
|
||||||
|
err: dict[str, Any] = {"success": False, "error": redact(message)}
|
||||||
|
if status is not None:
|
||||||
|
err["status_code"] = status
|
||||||
|
if body:
|
||||||
|
err["body"] = redact(body)[:200]
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
def make_success(**extra: Any) -> dict[str, Any]:
|
||||||
|
"""Build a stable success dict shape used by every provider client."""
|
||||||
|
out: dict[str, Any] = {"success": True}
|
||||||
|
out.update(extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(headers: Mapping[str, str], cap_s: float) -> float:
|
||||||
|
raw = headers.get("Retry-After") or headers.get("retry-after") or "2"
|
||||||
|
try:
|
||||||
|
seconds = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seconds = 2.0
|
||||||
|
return max(0.0, min(seconds, cap_s))
|
||||||
|
|
||||||
|
|
||||||
|
class HttpProviderClient:
|
||||||
|
"""Base for JSON-over-HTTP notification providers.
|
||||||
|
|
||||||
|
Subclasses call :meth:`request` instead of using ``self._session``
|
||||||
|
directly. ``request`` runs the SSRF guard (skippable for known-safe
|
||||||
|
upstreams via ``ssrf_validate=False``), enforces a per-request
|
||||||
|
timeout, retries 429/503 with a capped ``Retry-After``, and turns
|
||||||
|
transport/HTTP errors into the canonical ``{"success": False, ...}``
|
||||||
|
shape with secrets redacted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_max_retries: int = _MAX_RETRIES
|
||||||
|
# Settable per-instance so tests / hostile-upstream tuning can
|
||||||
|
# tighten the cap. Reads of this attribute fall through to the
|
||||||
|
# class default when no instance value has been set.
|
||||||
|
_MAX_RETRY_AFTER: float = _MAX_RETRY_AFTER_S
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
*,
|
||||||
|
timeout: aiohttp.ClientTimeout | None = None,
|
||||||
|
provider_name: str = "http",
|
||||||
|
) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._timeout = timeout or _DEFAULT_TIMEOUT
|
||||||
|
self._provider = provider_name
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json: Any = None,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
ssrf_validate: bool = True,
|
||||||
|
retry_statuses: frozenset[int] = _RETRY_STATUSES,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a single request with retry + redaction. Always returns a dict.
|
||||||
|
|
||||||
|
On 2xx returns ``{"success": True, "status_code": int, "json": ...
|
||||||
|
OR "body": str}``. On non-2xx returns the canonical error dict.
|
||||||
|
"""
|
||||||
|
if ssrf_validate:
|
||||||
|
try:
|
||||||
|
await avalidate_outbound_url(url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
return make_error(f"Unsafe URL: {redact_exc(err)}")
|
||||||
|
|
||||||
|
outbound_headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||||
|
outbound_headers.update(safe_headers(headers))
|
||||||
|
|
||||||
|
for attempt in range(1, self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
async with self._session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
json=json,
|
||||||
|
headers=outbound_headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
allow_redirects=False,
|
||||||
|
) as resp:
|
||||||
|
if resp.status in retry_statuses and attempt < self._max_retries:
|
||||||
|
delay = _retry_after_seconds(resp.headers, self._MAX_RETRY_AFTER)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s %s %s: HTTP %d, retrying after %.2fs (attempt %d/%d)",
|
||||||
|
self._provider, method, redact(url), resp.status,
|
||||||
|
delay, attempt, self._max_retries,
|
||||||
|
)
|
||||||
|
await resp.read() # drain body so connection can return to pool
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
if 200 <= resp.status < 300:
|
||||||
|
try:
|
||||||
|
payload: Any = await resp.json(content_type=None)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
payload = await resp.text()
|
||||||
|
return make_success(status_code=resp.status, json=payload)
|
||||||
|
body = await resp.text()
|
||||||
|
return make_error(
|
||||||
|
f"HTTP {resp.status}",
|
||||||
|
status=resp.status,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
|
||||||
|
# asyncio.CancelledError inherits from BaseException on
|
||||||
|
# 3.8+, so it is not caught here — good: cancellation
|
||||||
|
# must propagate.
|
||||||
|
if attempt < self._max_retries and isinstance(err, asyncio.TimeoutError):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s %s %s: timeout, retrying (attempt %d/%d)",
|
||||||
|
self._provider, method, redact(url),
|
||||||
|
attempt, self._max_retries,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(min(2 ** (attempt - 1), 5))
|
||||||
|
continue
|
||||||
|
return make_error(redact_exc(err))
|
||||||
|
|
||||||
|
# Retry budget exhausted on a retriable status.
|
||||||
|
return make_error("Rate limited (retries exhausted)")
|
||||||
@@ -2,22 +2,36 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import re
|
||||||
from typing import Any
|
import uuid
|
||||||
|
from typing import Any, Final
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ..http_base import _MAX_RETRY_AFTER_S, safe_headers
|
||||||
|
from ..redact import redact, redact_exc
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Monotonically increasing transaction counter for idempotent sends
|
# Matrix room IDs are ``!opaque:server.name`` per the spec. We also allow
|
||||||
_txn_counter = int(time.time() * 1000)
|
# the ``#alias:server`` form because some callers may pass aliases. The
|
||||||
|
# pattern's purpose is to reject obvious path-injection (``/``, ``..``,
|
||||||
|
# control chars, query/fragment chars) before the value reaches a URL.
|
||||||
|
_ROOM_ID_RE: Final = re.compile(r"^[!#][^\x00-\x1f\s/?#]{1,255}:[A-Za-z0-9.\-:]{1,255}$")
|
||||||
|
|
||||||
|
_DEFAULT_TIMEOUT: Final = aiohttp.ClientTimeout(total=30, connect=10)
|
||||||
|
_MAX_RETRIES: Final = 3
|
||||||
|
|
||||||
|
|
||||||
def _next_txn_id() -> str:
|
def _validate_room_id(room_id: str) -> str:
|
||||||
global _txn_counter
|
if not room_id:
|
||||||
_txn_counter += 1
|
raise ValueError("room_id is empty")
|
||||||
return str(_txn_counter)
|
if not _ROOM_ID_RE.match(room_id):
|
||||||
|
raise ValueError("room_id format is invalid")
|
||||||
|
return room_id
|
||||||
|
|
||||||
|
|
||||||
class MatrixClient:
|
class MatrixClient:
|
||||||
@@ -33,49 +47,67 @@ class MatrixClient:
|
|||||||
self._homeserver = homeserver_url.rstrip("/")
|
self._homeserver = homeserver_url.rstrip("/")
|
||||||
self._token = access_token
|
self._token = access_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _txn_id() -> str:
|
||||||
|
# uuid4 hex is collision-resistant across processes/restarts;
|
||||||
|
# eliminates the previous module-level counter race.
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
message: str,
|
message: str,
|
||||||
html_message: str | None = None,
|
html_message: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send a text message to a Matrix room.
|
"""Send a text message to a Matrix room."""
|
||||||
|
try:
|
||||||
|
room_id = _validate_room_id(room_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "error": f"Invalid room_id: {exc}"}
|
||||||
|
|
||||||
Args:
|
encoded_room = quote(room_id, safe="")
|
||||||
room_id: Internal room ID (e.g. !abc:matrix.org)
|
url = (
|
||||||
message: Plain text body
|
f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}"
|
||||||
html_message: Optional HTML-formatted body
|
f"/send/m.room.message/{self._txn_id()}"
|
||||||
"""
|
)
|
||||||
if not room_id:
|
|
||||||
return {"success": False, "error": "Missing room_id"}
|
|
||||||
|
|
||||||
txn_id = _next_txn_id()
|
body: dict[str, Any] = {"msgtype": "m.text", "body": message}
|
||||||
# URL-encode the room_id (! and : need encoding)
|
|
||||||
encoded_room = room_id.replace("!", "%21").replace(":", "%3A")
|
|
||||||
url = f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"msgtype": "m.text",
|
|
||||||
"body": message,
|
|
||||||
}
|
|
||||||
if html_message:
|
if html_message:
|
||||||
body["format"] = "org.matrix.custom.html"
|
body["format"] = "org.matrix.custom.html"
|
||||||
body["formatted_body"] = html_message
|
body["formatted_body"] = html_message
|
||||||
|
|
||||||
headers = {
|
headers = safe_headers({
|
||||||
"Authorization": f"Bearer {self._token}",
|
"Authorization": f"Bearer {self._token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
})
|
||||||
|
|
||||||
try:
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
async with self._session.put(
|
try:
|
||||||
url, json=body, headers=headers, allow_redirects=False,
|
async with self._session.put(
|
||||||
) as resp:
|
url, json=body, headers=headers,
|
||||||
if 200 <= resp.status < 300:
|
timeout=_DEFAULT_TIMEOUT, allow_redirects=False,
|
||||||
return {"success": True}
|
) as resp:
|
||||||
resp_body = await resp.text()
|
if 200 <= resp.status < 300:
|
||||||
if resp.status == 429:
|
return {"success": True}
|
||||||
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200])
|
resp_body = await resp.text()
|
||||||
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"}
|
if resp.status == 429 and attempt < _MAX_RETRIES:
|
||||||
except aiohttp.ClientError as e:
|
try:
|
||||||
return {"success": False, "error": str(e)}
|
wait_s = float(resp.headers.get("Retry-After", "2"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wait_s = 2.0
|
||||||
|
wait_s = max(0.0, min(wait_s, _MAX_RETRY_AFTER_S))
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Matrix rate limited, retrying after %.2fs (attempt %d/%d)",
|
||||||
|
wait_s, attempt, _MAX_RETRIES,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait_s)
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"HTTP {resp.status}: {redact(resp_body)[:200]}",
|
||||||
|
"status_code": resp.status,
|
||||||
|
}
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
|
||||||
|
return {"success": False, "error": redact_exc(e)}
|
||||||
|
|
||||||
|
return {"success": False, "error": "Rate limited (retries exhausted)"}
|
||||||
|
|||||||
@@ -3,18 +3,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Final
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ..http_base import HttpProviderClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PRIORITY_MIN: Final = 1
|
||||||
|
_PRIORITY_MAX: Final = 5
|
||||||
|
_DEFAULT_PRIORITY: Final = 3
|
||||||
|
_MAX_TAGS: Final = 10
|
||||||
|
_MAX_TAG_LEN: Final = 64
|
||||||
|
|
||||||
class NtfyClient:
|
|
||||||
|
def _strip_crlf(value: str) -> str:
|
||||||
|
"""Remove CR/LF — ntfy's JSON path is safe today, but the same fields
|
||||||
|
are used by the header API; defensive sanitization here means a future
|
||||||
|
refactor can't accidentally re-introduce header injection."""
|
||||||
|
return value.replace("\r", " ").replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyClient(HttpProviderClient):
|
||||||
"""Sends push notifications via ntfy server."""
|
"""Sends push notifications via ntfy server."""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||||
self._session = session
|
super().__init__(session, provider_name="ntfy")
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
@@ -22,41 +37,48 @@ class NtfyClient:
|
|||||||
topic: str,
|
topic: str,
|
||||||
message: str,
|
message: str,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
priority: int = 3,
|
priority: int = _DEFAULT_PRIORITY,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
click_url: str | None = None,
|
click_url: str | None = None,
|
||||||
auth_token: str | None = None,
|
auth_token: str | None = None,
|
||||||
|
markdown: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send a push notification to an ntfy topic."""
|
"""Send a push notification to an ntfy topic."""
|
||||||
if not server_url or not topic:
|
if not server_url or not topic:
|
||||||
return {"success": False, "error": "Missing server_url or topic"}
|
return {"success": False, "error": "Missing server_url or topic"}
|
||||||
|
|
||||||
url = f"{server_url.rstrip('/')}"
|
topic = _strip_crlf(topic).strip()
|
||||||
|
if not topic:
|
||||||
|
return {"success": False, "error": "Topic is empty after sanitization"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
priority_int = int(priority) if priority is not None else _DEFAULT_PRIORITY
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
priority_int = _DEFAULT_PRIORITY
|
||||||
|
priority_int = max(_PRIORITY_MIN, min(priority_int, _PRIORITY_MAX))
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"message": message,
|
"message": message,
|
||||||
"markdown": True,
|
"markdown": bool(markdown),
|
||||||
}
|
}
|
||||||
if title:
|
if title:
|
||||||
payload["title"] = title
|
payload["title"] = _strip_crlf(title)
|
||||||
if priority != 3:
|
if priority_int != _DEFAULT_PRIORITY:
|
||||||
payload["priority"] = priority
|
payload["priority"] = priority_int
|
||||||
if tags:
|
if tags:
|
||||||
payload["tags"] = tags
|
cleaned = [
|
||||||
|
_strip_crlf(str(t))[:_MAX_TAG_LEN]
|
||||||
|
for t in tags[:_MAX_TAGS]
|
||||||
|
if t
|
||||||
|
]
|
||||||
|
if cleaned:
|
||||||
|
payload["tags"] = cleaned
|
||||||
if click_url:
|
if click_url:
|
||||||
payload["click"] = click_url
|
payload["click"] = _strip_crlf(click_url)
|
||||||
|
|
||||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
headers: dict[str, str] = {}
|
||||||
if auth_token:
|
if auth_token:
|
||||||
headers["Authorization"] = f"Bearer {auth_token}"
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
|
||||||
try:
|
return await self.request("POST", server_url.rstrip("/"), json=payload, headers=headers)
|
||||||
async with self._session.post(
|
|
||||||
url, json=payload, headers=headers, allow_redirects=False,
|
|
||||||
) as resp:
|
|
||||||
if 200 <= resp.status < 300:
|
|
||||||
return {"success": True}
|
|
||||||
body = await resp.text()
|
|
||||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
|
||||||
except aiohttp.ClientError as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|||||||
@@ -2,47 +2,88 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Final
|
||||||
|
|
||||||
from notify_bridge_core.storage import StorageBackend
|
from notify_bridge_core.storage import StorageBackend
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bound on queue length. Without a cap, a misconfigured quiet-hour
|
||||||
|
# window plus high event throughput grows the persisted file unboundedly
|
||||||
|
# and every enqueue rewrites the whole file (O(n²) total writes). When
|
||||||
|
# the cap is hit we drop the oldest entry (FIFO) so the most recent
|
||||||
|
# events still reach the recipient when the window opens.
|
||||||
|
DEFAULT_MAX_QUEUE_SIZE: Final = 1000
|
||||||
|
|
||||||
|
|
||||||
class NotificationQueue:
|
class NotificationQueue:
|
||||||
"""Persistent queue for notifications deferred during quiet hours."""
|
"""Persistent queue for notifications deferred during quiet hours."""
|
||||||
|
|
||||||
def __init__(self, backend: StorageBackend) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
backend: StorageBackend,
|
||||||
|
*,
|
||||||
|
max_size: int = DEFAULT_MAX_QUEUE_SIZE,
|
||||||
|
) -> None:
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self._data: dict[str, Any] | None = None
|
self._data: dict[str, Any] | None = None
|
||||||
|
self._max_size = max_size
|
||||||
|
# Coordinates load / enqueue / clear / remove so a write-while-load
|
||||||
|
# race can't leave the in-memory copy out of sync with disk and so
|
||||||
|
# bulk operations don't interleave their reads-then-writes.
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_schema(data: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("queue"), list):
|
||||||
|
return {"queue": []}
|
||||||
|
return data
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
async def async_load(self) -> None:
|
||||||
self._data = await self._backend.load() or {"queue": []}
|
async with self._lock:
|
||||||
|
raw = await self._backend.load()
|
||||||
|
self._data = self._ensure_schema(raw)
|
||||||
|
|
||||||
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
|
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
|
||||||
if self._data is None:
|
async with self._lock:
|
||||||
self._data = {"queue": []}
|
if self._data is None:
|
||||||
self._data["queue"].append({
|
self._data = {"queue": []}
|
||||||
"params": notification_params,
|
queue: list[dict[str, Any]] = self._data["queue"]
|
||||||
"queued_at": datetime.now(timezone.utc).isoformat(),
|
queue.append({
|
||||||
})
|
"params": notification_params,
|
||||||
await self._backend.save(self._data)
|
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
if self._max_size > 0 and len(queue) > self._max_size:
|
||||||
|
# Drop oldest (FIFO) so a new event can still land.
|
||||||
|
drop = len(queue) - self._max_size
|
||||||
|
_LOGGER.warning(
|
||||||
|
"NotificationQueue: dropping %d oldest entries (cap=%d)",
|
||||||
|
drop, self._max_size,
|
||||||
|
)
|
||||||
|
del queue[:drop]
|
||||||
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
def get_all(self) -> list[dict[str, Any]]:
|
def get_all(self) -> list[dict[str, Any]]:
|
||||||
if not self._data:
|
if not self._data:
|
||||||
return []
|
return []
|
||||||
return list(self._data.get("queue", []))
|
# Deep copy so callers can iterate / mutate without corrupting the
|
||||||
|
# in-memory queue. The cost is bounded by ``max_size``.
|
||||||
|
return copy.deepcopy(list(self._data.get("queue", [])))
|
||||||
|
|
||||||
def has_pending(self) -> bool:
|
def has_pending(self) -> bool:
|
||||||
return bool(self._data and self._data.get("queue"))
|
return bool(self._data and self._data.get("queue"))
|
||||||
|
|
||||||
async def async_clear(self) -> None:
|
async def async_clear(self) -> None:
|
||||||
if self._data:
|
async with self._lock:
|
||||||
self._data["queue"] = []
|
if self._data:
|
||||||
await self._backend.save(self._data)
|
self._data["queue"] = []
|
||||||
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
async def async_remove(self) -> None:
|
async def async_remove(self) -> None:
|
||||||
await self._backend.remove()
|
async with self._lock:
|
||||||
self._data = None
|
await self._backend.remove()
|
||||||
|
self._data = None
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -70,51 +70,64 @@ class MatrixReceiver(Receiver):
|
|||||||
room_id: str = ""
|
room_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
_ReceiverFactory = Callable[[str, dict[str, Any]], Receiver]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
_RECEIVER_FACTORIES: dict[str, _ReceiverFactory] = {
|
||||||
|
"telegram": lambda locale, config: TelegramReceiver(
|
||||||
|
locale=locale, config=config, chat_id=str(config.get("chat_id", "")),
|
||||||
|
),
|
||||||
|
"webhook": lambda locale, config: WebhookReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
url=str(config.get("url", "")),
|
||||||
|
headers=dict(config.get("headers", {}) or {}),
|
||||||
|
),
|
||||||
|
"email": lambda locale, config: EmailReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
email=str(config.get("email", "")),
|
||||||
|
name=str(config.get("name", "")),
|
||||||
|
),
|
||||||
|
"discord": lambda locale, config: DiscordReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
webhook_url=str(config.get("webhook_url", "")),
|
||||||
|
),
|
||||||
|
"slack": lambda locale, config: SlackReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
webhook_url=str(config.get("webhook_url", "")),
|
||||||
|
),
|
||||||
|
"ntfy": lambda locale, config: NtfyReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
topic=str(config.get("topic", "")),
|
||||||
|
priority=_coerce_int(config.get("priority"), 3),
|
||||||
|
),
|
||||||
|
"matrix": lambda locale, config: MatrixReceiver(
|
||||||
|
locale=locale, config=config,
|
||||||
|
room_id=str(config.get("room_id", "")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_receiver_factory(target_type: str, factory: _ReceiverFactory) -> None:
|
||||||
|
"""Register a receiver factory for an out-of-tree target type."""
|
||||||
|
_RECEIVER_FACTORIES[target_type] = factory
|
||||||
|
|
||||||
|
|
||||||
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
|
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
|
||||||
"""Factory: build typed Receiver from target type and config dict."""
|
"""Factory: build typed Receiver from target type and config dict.
|
||||||
if target_type == "telegram":
|
|
||||||
return TelegramReceiver(
|
Falls back to a base ``Receiver`` for unknown target types so callers
|
||||||
locale=locale,
|
that handle types defensively still receive a usable object — but the
|
||||||
config=config,
|
dispatcher rejects them with ``"Unknown target type"`` so a typo can't
|
||||||
chat_id=str(config.get("chat_id", "")),
|
silently route to nowhere.
|
||||||
)
|
"""
|
||||||
if target_type == "webhook":
|
factory = _RECEIVER_FACTORIES.get(target_type)
|
||||||
return WebhookReceiver(
|
if factory is None:
|
||||||
locale=locale,
|
return Receiver(locale=locale, config=config)
|
||||||
config=config,
|
return factory(locale, config)
|
||||||
url=config.get("url", ""),
|
|
||||||
headers=config.get("headers", {}),
|
|
||||||
)
|
|
||||||
if target_type == "email":
|
|
||||||
return EmailReceiver(
|
|
||||||
locale=locale,
|
|
||||||
config=config,
|
|
||||||
email=config.get("email", ""),
|
|
||||||
name=config.get("name", ""),
|
|
||||||
)
|
|
||||||
if target_type == "discord":
|
|
||||||
return DiscordReceiver(
|
|
||||||
locale=locale,
|
|
||||||
config=config,
|
|
||||||
webhook_url=config.get("webhook_url", ""),
|
|
||||||
)
|
|
||||||
if target_type == "slack":
|
|
||||||
return SlackReceiver(
|
|
||||||
locale=locale,
|
|
||||||
config=config,
|
|
||||||
webhook_url=config.get("webhook_url", ""),
|
|
||||||
)
|
|
||||||
if target_type == "ntfy":
|
|
||||||
return NtfyReceiver(
|
|
||||||
locale=locale,
|
|
||||||
config=config,
|
|
||||||
topic=config.get("topic", ""),
|
|
||||||
priority=config.get("priority", 3),
|
|
||||||
)
|
|
||||||
if target_type == "matrix":
|
|
||||||
return MatrixReceiver(
|
|
||||||
locale=locale,
|
|
||||||
config=config,
|
|
||||||
room_id=config.get("room_id", ""),
|
|
||||||
)
|
|
||||||
return Receiver(locale=locale, config=config)
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Secret-redaction helpers for log lines and error strings.
|
||||||
|
|
||||||
|
Notification clients embed secrets in URLs (Telegram bot tokens) and
|
||||||
|
Authorization headers (Matrix access tokens, ntfy bearer tokens). When
|
||||||
|
those secrets surface in ``aiohttp.ClientError.__str__``, response
|
||||||
|
bodies, or operator-visible error fields, they leak into logs and into
|
||||||
|
the per-target result dict that callers may forward upstream. ``redact``
|
||||||
|
returns a defanged copy safe for both contexts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# api.telegram.org/bot<digits>:<token>/<method>
|
||||||
|
_TELEGRAM_BOT_TOKEN_RE: Final = re.compile(
|
||||||
|
r"(api\.telegram\.org/bot)\d+:[A-Za-z0-9_-]+", re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Authorization: Bearer <token> (header form, case-insensitive)
|
||||||
|
_BEARER_RE: Final = re.compile(r"(Bearer\s+)[A-Za-z0-9._\-+/=]+", re.IGNORECASE)
|
||||||
|
# Discord webhook: /api/webhooks/<id>/<token>
|
||||||
|
_DISCORD_WEBHOOK_RE: Final = re.compile(
|
||||||
|
r"(discord(?:app)?\.com/api/webhooks/\d+/)[A-Za-z0-9_-]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Slack webhook path: /services/T.../B.../<token>
|
||||||
|
_SLACK_WEBHOOK_RE: Final = re.compile(
|
||||||
|
r"(hooks\.slack\.com/services/[A-Z0-9]+/[A-Z0-9]+/)[A-Za-z0-9]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# URL userinfo: scheme://user:password@host
|
||||||
|
_URL_USERINFO_RE: Final = re.compile(
|
||||||
|
r"([a-z][a-z0-9+\-.]*://)[^/@\s]+:[^/@\s]+@",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Common token query parameters
|
||||||
|
_QUERY_TOKEN_RE: Final = re.compile(
|
||||||
|
r"([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text: str) -> str:
|
||||||
|
"""Return ``text`` with known secret patterns replaced by ``***``.
|
||||||
|
|
||||||
|
Idempotent and safe to call on already-redacted strings. Always
|
||||||
|
returns a ``str``; non-strings are coerced via ``str()`` so callers
|
||||||
|
can pass exception instances directly.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
text = str(text)
|
||||||
|
text = _TELEGRAM_BOT_TOKEN_RE.sub(r"\1***", text)
|
||||||
|
text = _DISCORD_WEBHOOK_RE.sub(r"\1***", text)
|
||||||
|
text = _SLACK_WEBHOOK_RE.sub(r"\1***", text)
|
||||||
|
text = _BEARER_RE.sub(r"\1***", text)
|
||||||
|
text = _URL_USERINFO_RE.sub(r"\1***@", text)
|
||||||
|
text = _QUERY_TOKEN_RE.sub(r"\1***", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def redact_exc(err: BaseException) -> str:
|
||||||
|
"""Redact-and-stringify an exception. Convenience for error fields."""
|
||||||
|
return redact(str(err))
|
||||||
@@ -7,14 +7,16 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ..http_base import HttpProviderClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SlackClient:
|
class SlackClient(HttpProviderClient):
|
||||||
"""Sends messages via Slack incoming webhook URLs."""
|
"""Sends messages via Slack incoming webhook URLs."""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
def __init__(self, session: aiohttp.ClientSession) -> None:
|
||||||
self._session = session
|
super().__init__(session, provider_name="slack")
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
@@ -33,19 +35,4 @@ class SlackClient:
|
|||||||
if icon_emoji:
|
if icon_emoji:
|
||||||
payload["icon_emoji"] = icon_emoji
|
payload["icon_emoji"] = icon_emoji
|
||||||
|
|
||||||
try:
|
return await self.request("POST", webhook_url, json=payload)
|
||||||
async with self._session.post(
|
|
||||||
webhook_url,
|
|
||||||
json=payload,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
allow_redirects=False,
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 429:
|
|
||||||
_LOGGER.warning("Slack rate limited")
|
|
||||||
return {"success": False, "error": "Rate limited by Slack"}
|
|
||||||
if 200 <= resp.status < 300:
|
|
||||||
return {"success": True}
|
|
||||||
body = await resp.text()
|
|
||||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
|
||||||
except aiohttp.ClientError as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
"""Outbound URL validation to mitigate SSRF attacks.
|
"""Outbound URL validation to mitigate SSRF attacks.
|
||||||
|
|
||||||
User-controlled URLs (provider `url`, webhook target `url`, shared-link
|
User-controlled URLs (provider ``url``, webhook target ``url``,
|
||||||
base URLs, image downloads) must be validated before any HTTP request is
|
shared-link base URLs, image downloads) must be validated before any
|
||||||
issued. This module rejects schemes other than http/https and blocks
|
HTTP request is issued. This module rejects schemes other than
|
||||||
destinations that resolve to private, loopback, link-local, or unspecified
|
http/https and blocks destinations that resolve to private, loopback,
|
||||||
address ranges.
|
link-local, unspecified, CGNAT (100.64.0.0/10), or IPv4-mapped IPv6
|
||||||
|
ranges.
|
||||||
|
|
||||||
|
DNS rebinding mitigation
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
``avalidate_outbound_url`` returns the original URL on success, but
|
||||||
|
also returns the resolved IP it actually validated. Callers that pass
|
||||||
|
the validated URL straight into ``aiohttp`` are vulnerable to a
|
||||||
|
DNS-rebinding attack: the validator's ``getaddrinfo`` returns a public
|
||||||
|
IP; aiohttp's connect-time resolution returns ``127.0.0.1``. To close
|
||||||
|
that gap, use :func:`build_ssrf_safe_session` (or
|
||||||
|
:class:`PinnedResolver`) so the resolved IP from the validation step is
|
||||||
|
the one aiohttp connects to.
|
||||||
|
|
||||||
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
|
Set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the environment for
|
||||||
development against localhost services.
|
development against localhost services.
|
||||||
@@ -17,12 +29,20 @@ import ipaddress
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
||||||
_ALLOWED_SCHEMES = {"http", "https"}
|
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
||||||
|
|
||||||
|
# Carrier-grade NAT range. Not in stdlib's ``is_private``; an attacker
|
||||||
|
# pointing a domain at a CGNAT IP could reach the operator's ISP-side
|
||||||
|
# routing infrastructure. RFC 6598.
|
||||||
|
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
|
||||||
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
|
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
@@ -36,7 +56,29 @@ class UnsafeURLError(ValueError):
|
|||||||
"""Raised when a URL targets a disallowed network destination."""
|
"""Raised when a URL targets a disallowed network destination."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidatedURL:
|
||||||
|
"""Result of validating an outbound URL.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
url: The original URL string (unchanged).
|
||||||
|
host: Hostname extracted from the URL (lower-cased, IDN-encoded).
|
||||||
|
ip: Resolved IP address that passed the block-range check, as a
|
||||||
|
string. Pass to :class:`PinnedResolver` to defeat DNS
|
||||||
|
rebinding by reusing this exact IP at connect time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
host: str
|
||||||
|
ip: str
|
||||||
|
|
||||||
|
|
||||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
# An IPv4-mapped IPv6 like ``::ffff:127.0.0.1`` is NOT considered
|
||||||
|
# ``is_private`` etc. by stdlib — the v4 view holds those flags. So
|
||||||
|
# we unwrap before checking.
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
return (
|
return (
|
||||||
ip.is_private
|
ip.is_private
|
||||||
or ip.is_loopback
|
or ip.is_loopback
|
||||||
@@ -44,22 +86,54 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|||||||
or ip.is_multicast
|
or ip.is_multicast
|
||||||
or ip.is_reserved
|
or ip.is_reserved
|
||||||
or ip.is_unspecified
|
or ip.is_unspecified
|
||||||
|
or (isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_host_repr(host: str) -> str:
|
||||||
|
"""Return ``host`` shortened/escaped for safe inclusion in error text."""
|
||||||
|
h = host[:64].replace("\r", "").replace("\n", "")
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_host(parsed_host: str) -> str:
|
||||||
|
"""Normalize a hostname: lowercase, strip trailing dot, IDN-encode."""
|
||||||
|
host = parsed_host.lower()
|
||||||
|
if host.endswith("."):
|
||||||
|
host = host[:-1]
|
||||||
|
# Strip IPv6 zone id ("fe80::1%eth0") — must not reach the resolver.
|
||||||
|
if "%" in host:
|
||||||
|
host = host.split("%", 1)[0]
|
||||||
|
# IDN-encode unicode hostnames so we don't downgrade to confusables
|
||||||
|
# in any later log/output and so getaddrinfo gets the ascii form.
|
||||||
|
try:
|
||||||
|
if any(ord(c) > 127 for c in host):
|
||||||
|
host = host.encode("idna").decode("ascii")
|
||||||
|
except UnicodeError:
|
||||||
|
# Caller will fail on resolution; leave as-is so the error path
|
||||||
|
# surfaces a "DNS resolution failed" rather than a stack trace.
|
||||||
|
pass
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
def _check_scheme_host(url: str) -> tuple[str, str]:
|
def _check_scheme_host(url: str) -> tuple[str, str]:
|
||||||
if not isinstance(url, str) or not url:
|
if not isinstance(url, str) or not url:
|
||||||
raise UnsafeURLError("URL is empty")
|
raise UnsafeURLError("URL is empty")
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.scheme not in _ALLOWED_SCHEMES:
|
scheme = parsed.scheme.lower()
|
||||||
raise UnsafeURLError(f"Scheme '{parsed.scheme}' not allowed")
|
if scheme not in _ALLOWED_SCHEMES:
|
||||||
|
raise UnsafeURLError(f"Scheme '{scheme[:16]}' not allowed")
|
||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
if not host:
|
if not host:
|
||||||
raise UnsafeURLError("URL has no host")
|
raise UnsafeURLError("URL has no host")
|
||||||
return parsed.scheme, host
|
return scheme, _normalize_host(host)
|
||||||
|
|
||||||
|
|
||||||
def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
|
def _select_addresses(
|
||||||
|
host: str, infos: list[tuple],
|
||||||
|
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||||
|
"""Return parsed, non-blocked IPs from ``getaddrinfo`` results."""
|
||||||
|
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
sockaddr = info[4]
|
sockaddr = info[4]
|
||||||
try:
|
try:
|
||||||
@@ -67,64 +141,143 @@ def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if _is_blocked_ip(ip):
|
if _is_blocked_ip(ip):
|
||||||
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
|
raise UnsafeURLError(
|
||||||
|
f"Host {_safe_host_repr(host)} resolves to blocked address {ip}"
|
||||||
|
)
|
||||||
|
addrs.append(ip)
|
||||||
|
if not addrs:
|
||||||
|
raise UnsafeURLError(f"Host {_safe_host_repr(host)} has no usable address")
|
||||||
|
return addrs
|
||||||
|
|
||||||
|
|
||||||
def validate_outbound_url(url: str) -> str:
|
def validate_outbound_url(url: str) -> str:
|
||||||
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
||||||
|
|
||||||
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
|
.. deprecated::
|
||||||
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
|
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
|
||||||
private addresses are permitted but the scheme check still applies.
|
:func:`avalidate_outbound_url` from async code paths so the
|
||||||
|
event loop isn't blocked, and use :func:`build_ssrf_safe_session`
|
||||||
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
|
to defeat DNS rebinding.
|
||||||
:func:`avalidate_outbound_url` from async code paths.
|
|
||||||
"""
|
"""
|
||||||
_, host = _check_scheme_host(url)
|
_, host = _check_scheme_host(url)
|
||||||
|
|
||||||
if _ALLOW_PRIVATE:
|
if _ALLOW_PRIVATE:
|
||||||
return url
|
return url
|
||||||
|
|
||||||
# Literal IP host
|
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host)
|
ip = ipaddress.ip_address(host)
|
||||||
if _is_blocked_ip(ip):
|
if _is_blocked_ip(ip):
|
||||||
raise UnsafeURLError(f"Host {host} is in a blocked range")
|
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
|
||||||
return url
|
return url
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
infos = socket.getaddrinfo(host, None)
|
infos = socket.getaddrinfo(host, None)
|
||||||
except socket.gaierror as exc:
|
except (socket.gaierror, UnicodeError, OSError) as exc:
|
||||||
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
# ``UnicodeError`` covers IDNA failures (labels >63 chars, malformed
|
||||||
_check_resolved_addresses(host, infos)
|
# unicode) which getaddrinfo surfaces as encoding errors rather than
|
||||||
|
# gaierror. ``OSError`` covers transient resolver failures on some
|
||||||
|
# platforms.
|
||||||
|
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
|
||||||
|
_select_addresses(host, infos)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
async def avalidate_outbound_url(url: str) -> str:
|
async def avalidate_outbound_url(url: str) -> str:
|
||||||
"""Async variant that resolves DNS via the running loop's resolver.
|
"""Async variant — returns the URL on success.
|
||||||
|
|
||||||
Use this from ``async def`` code paths to avoid blocking the event
|
For DNS-rebinding-safe usage, prefer :func:`avalidate_outbound_url_full`
|
||||||
loop on DNS lookups.
|
which also returns the resolved IP for connect-time pinning.
|
||||||
|
"""
|
||||||
|
result = await avalidate_outbound_url_full(url)
|
||||||
|
return result.url
|
||||||
|
|
||||||
|
|
||||||
|
async def avalidate_outbound_url_full(url: str) -> ValidatedURL:
|
||||||
|
"""Validate ``url`` and return a :class:`ValidatedURL` on success.
|
||||||
|
|
||||||
|
The returned ``ip`` field is the IP that passed the block-range
|
||||||
|
check. Pair with :class:`PinnedResolver` so aiohttp connects to that
|
||||||
|
exact IP and a malicious DNS server can't swap in a private address
|
||||||
|
after validation.
|
||||||
"""
|
"""
|
||||||
_, host = _check_scheme_host(url)
|
_, host = _check_scheme_host(url)
|
||||||
|
|
||||||
if _ALLOW_PRIVATE:
|
if _ALLOW_PRIVATE:
|
||||||
return url
|
# In dev mode we still resolve to give a usable IP, but we don't
|
||||||
|
# gate on the result.
|
||||||
|
try:
|
||||||
|
ip = str(ipaddress.ip_address(host))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
infos = await loop.getaddrinfo(host, None)
|
||||||
|
ip = infos[0][4][0] if infos else host
|
||||||
|
except (socket.gaierror, OSError):
|
||||||
|
ip = host
|
||||||
|
return ValidatedURL(url=url, host=host, ip=ip)
|
||||||
|
|
||||||
|
# Literal IP host
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host)
|
ip_obj = ipaddress.ip_address(host)
|
||||||
if _is_blocked_ip(ip):
|
if _is_blocked_ip(ip_obj):
|
||||||
raise UnsafeURLError(f"Host {host} is in a blocked range")
|
raise UnsafeURLError(f"Host {_safe_host_repr(host)} is in a blocked range")
|
||||||
return url
|
return ValidatedURL(url=url, host=host, ip=str(ip_obj))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
try:
|
try:
|
||||||
infos = await loop.getaddrinfo(host, None)
|
infos = await loop.getaddrinfo(host, None)
|
||||||
except socket.gaierror as exc:
|
except (socket.gaierror, UnicodeError, OSError) as exc:
|
||||||
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
raise UnsafeURLError(f"DNS resolution failed for {_safe_host_repr(host)}") from exc
|
||||||
_check_resolved_addresses(host, infos)
|
addrs = _select_addresses(host, infos)
|
||||||
return url
|
return ValidatedURL(url=url, host=host, ip=str(addrs[0]))
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedResolver(aiohttp.abc.AbstractResolver):
|
||||||
|
"""aiohttp resolver that returns a fixed (host, ip) mapping.
|
||||||
|
|
||||||
|
Used to pin the resolved IP from :func:`avalidate_outbound_url_full`
|
||||||
|
so aiohttp's connect-time resolution can't be tricked by DNS
|
||||||
|
rebinding into using a different IP than the one we validated.
|
||||||
|
|
||||||
|
Falls back to :class:`aiohttp.AsyncResolver` (or default) for any
|
||||||
|
host not explicitly pinned, so a single resolver instance can be
|
||||||
|
reused across multiple validated URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mapping: dict[str, str] | None = None) -> None:
|
||||||
|
self._map: dict[str, str] = dict(mapping or {})
|
||||||
|
self._fallback: aiohttp.abc.AbstractResolver | None = None
|
||||||
|
|
||||||
|
def pin(self, host: str, ip: str) -> None:
|
||||||
|
self._map[host.lower()] = ip
|
||||||
|
|
||||||
|
async def resolve(
|
||||||
|
self, host: str, port: int = 0, family: int = socket.AF_INET,
|
||||||
|
) -> list[dict]:
|
||||||
|
ip = self._map.get(host.lower())
|
||||||
|
if ip is not None:
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
ip_obj = None
|
||||||
|
if ip_obj is not None:
|
||||||
|
fam = socket.AF_INET6 if ip_obj.version == 6 else socket.AF_INET
|
||||||
|
return [{
|
||||||
|
"hostname": host,
|
||||||
|
"host": ip,
|
||||||
|
"port": port,
|
||||||
|
"family": fam,
|
||||||
|
"proto": 0,
|
||||||
|
"flags": socket.AI_NUMERICHOST,
|
||||||
|
}]
|
||||||
|
if self._fallback is None:
|
||||||
|
self._fallback = aiohttp.ThreadedResolver()
|
||||||
|
return await self._fallback.resolve(host, port, family)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._fallback is not None:
|
||||||
|
await self._fallback.close()
|
||||||
|
|||||||
@@ -2,16 +2,29 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Final
|
||||||
|
|
||||||
from notify_bridge_core.storage import StorageBackend
|
from notify_bridge_core.storage import StorageBackend
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
|
||||||
DEFAULT_MAX_ENTRIES = 5000
|
DEFAULT_MAX_ENTRIES: Final = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: str | None) -> datetime | None:
|
||||||
|
"""Parse an ISO-8601 timestamp tolerantly. Returns ``None`` on failure."""
|
||||||
|
if not value or not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Python <3.11 doesn't accept "Z"; normalize to +00:00.
|
||||||
|
v = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||||
|
return datetime.fromisoformat(v)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TelegramFileCache:
|
class TelegramFileCache:
|
||||||
@@ -25,7 +38,17 @@ class TelegramFileCache:
|
|||||||
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||||
should be triggered by visual change, not elapsed time.
|
should be triggered by visual change, not elapsed time.
|
||||||
|
|
||||||
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
``max_entries`` always applies as a FIFO size cap (oldest-cached first).
|
||||||
|
|
||||||
|
Concurrency
|
||||||
|
~~~~~~~~~~~
|
||||||
|
All mutators take an internal ``asyncio.Lock`` so concurrent
|
||||||
|
media-group sends can't interleave a read-time invalidation with a
|
||||||
|
bulk write and corrupt the underlying dict (``RuntimeError:
|
||||||
|
dictionary changed size during iteration``) or lose just-written
|
||||||
|
entries. Reads do not take the lock — they are O(1) dict lookups —
|
||||||
|
but ``get`` uses a snapshot reference so it cannot mutate the data
|
||||||
|
structure under another task.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -40,35 +63,40 @@ class TelegramFileCache:
|
|||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
self._use_thumbhash = use_thumbhash
|
self._use_thumbhash = use_thumbhash
|
||||||
self._max_entries = max_entries
|
self._max_entries = max_entries
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
async def async_load(self) -> None:
|
||||||
self._data = await self._backend.load() or {"files": {}}
|
async with self._lock:
|
||||||
await self._cleanup_expired()
|
self._data = await self._backend.load() or {"files": {}}
|
||||||
|
await self._cleanup_expired_locked()
|
||||||
|
|
||||||
async def _cleanup_expired(self) -> None:
|
async def _cleanup_expired_locked(self) -> None:
|
||||||
|
"""Caller must hold ``self._lock``."""
|
||||||
if not self._data or "files" not in self._data:
|
if not self._data or "files" not in self._data:
|
||||||
return
|
return
|
||||||
files = self._data["files"]
|
files: dict[str, dict[str, Any]] = self._data["files"]
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
|
||||||
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
|
||||||
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
|
||||||
# cache forever, subject only to the size cap.
|
|
||||||
if not self._use_thumbhash and self._ttl_seconds > 0:
|
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
expired = [
|
expired: list[str] = []
|
||||||
url for url, entry in files.items()
|
for url, entry in list(files.items()):
|
||||||
if entry.get("cached_at") and
|
cached_at = _parse_iso(entry.get("cached_at"))
|
||||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
if cached_at is None:
|
||||||
]
|
continue
|
||||||
|
if cached_at.tzinfo is None:
|
||||||
|
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||||
|
if (now - cached_at).total_seconds() > self._ttl_seconds:
|
||||||
|
expired.append(url)
|
||||||
for key in expired:
|
for key in expired:
|
||||||
del files[key]
|
del files[key]
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
|
||||||
if self._max_entries > 0 and len(files) > self._max_entries:
|
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
sorted_keys = sorted(
|
||||||
|
files,
|
||||||
|
key=lambda k: _parse_iso(files[k].get("cached_at")) or datetime.min.replace(tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
for key in sorted_keys[: len(files) - self._max_entries]:
|
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||||
del files[key]
|
del files[key]
|
||||||
changed = True
|
changed = True
|
||||||
@@ -80,7 +108,10 @@ class TelegramFileCache:
|
|||||||
if not self._data or "files" not in self._data:
|
if not self._data or "files" not in self._data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
entry = self._data["files"].get(key)
|
# Take a local reference so a concurrent ``async_set`` rebuilding
|
||||||
|
# the dict cannot pull the rug out mid-read.
|
||||||
|
files = self._data["files"]
|
||||||
|
entry = files.get(key)
|
||||||
if not entry:
|
if not entry:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -88,19 +119,23 @@ class TelegramFileCache:
|
|||||||
if thumbhash is not None:
|
if thumbhash is not None:
|
||||||
stored = entry.get("thumbhash")
|
stored = entry.get("thumbhash")
|
||||||
if stored and stored != thumbhash:
|
if stored and stored != thumbhash:
|
||||||
del self._data["files"][key]
|
# Mark stale — actual deletion happens lock-protected
|
||||||
|
# in the next mutation. Returning None is sufficient
|
||||||
|
# for the caller to skip the cache hit.
|
||||||
return None
|
return None
|
||||||
elif self._ttl_seconds > 0:
|
elif self._ttl_seconds > 0:
|
||||||
cached_at_str = entry.get("cached_at")
|
cached_at = _parse_iso(entry.get("cached_at"))
|
||||||
if cached_at_str:
|
if cached_at is not None:
|
||||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
if cached_at.tzinfo is None:
|
||||||
|
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||||
|
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||||
if age > self._ttl_seconds:
|
if age > self._ttl_seconds:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file_id": entry.get("file_id"),
|
"file_id": entry.get("file_id"),
|
||||||
"type": entry.get("type"),
|
"type": entry.get("type"),
|
||||||
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
|
"size": entry.get("size"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def async_set(
|
async def async_set(
|
||||||
@@ -111,21 +146,22 @@ class TelegramFileCache:
|
|||||||
thumbhash: str | None = None,
|
thumbhash: str | None = None,
|
||||||
size: int | None = None,
|
size: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._data is None:
|
async with self._lock:
|
||||||
self._data = {"files": {}}
|
if self._data is None:
|
||||||
|
self._data = {"files": {}}
|
||||||
|
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"type": media_type,
|
"type": media_type,
|
||||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
if thumbhash is not None:
|
if thumbhash is not None:
|
||||||
entry["thumbhash"] = thumbhash
|
entry["thumbhash"] = thumbhash
|
||||||
if size is not None:
|
if size is not None:
|
||||||
entry["size"] = size
|
entry["size"] = size
|
||||||
|
|
||||||
self._data["files"][key] = entry
|
self._data["files"][key] = entry
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
async def async_set_many(
|
async def async_set_many(
|
||||||
self,
|
self,
|
||||||
@@ -139,32 +175,34 @@ class TelegramFileCache:
|
|||||||
"""
|
"""
|
||||||
if not entries:
|
if not entries:
|
||||||
return
|
return
|
||||||
if self._data is None:
|
async with self._lock:
|
||||||
self._data = {"files": {}}
|
if self._data is None:
|
||||||
|
self._data = {"files": {}}
|
||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
for item in entries:
|
for item in entries:
|
||||||
if len(item) == 5:
|
if len(item) == 5:
|
||||||
key, file_id, media_type, thumbhash, size = item
|
key, file_id, media_type, thumbhash, size = item
|
||||||
else:
|
else:
|
||||||
key, file_id, media_type, thumbhash = item
|
key, file_id, media_type, thumbhash = item
|
||||||
size = None
|
size = None
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"type": media_type,
|
"type": media_type,
|
||||||
"cached_at": now_iso,
|
"cached_at": now_iso,
|
||||||
}
|
}
|
||||||
if thumbhash is not None:
|
if thumbhash is not None:
|
||||||
entry["thumbhash"] = thumbhash
|
entry["thumbhash"] = thumbhash
|
||||||
if size is not None:
|
if size is not None:
|
||||||
entry["size"] = size
|
entry["size"] = size
|
||||||
self._data["files"][key] = entry
|
self._data["files"][key] = entry
|
||||||
|
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
async def async_remove(self) -> None:
|
async def async_remove(self) -> None:
|
||||||
await self._backend.remove()
|
async with self._lock:
|
||||||
self._data = None
|
await self._backend.remove()
|
||||||
|
self._data = None
|
||||||
|
|
||||||
def stats(self) -> dict[str, Any]:
|
def stats(self) -> dict[str, Any]:
|
||||||
"""Return summary stats about the current cache contents.
|
"""Return summary stats about the current cache contents.
|
||||||
@@ -172,25 +210,33 @@ class TelegramFileCache:
|
|||||||
Includes the number of cached entries, total tracked size in bytes
|
Includes the number of cached entries, total tracked size in bytes
|
||||||
(only counts entries with a recorded ``size``), and the oldest /
|
(only counts entries with a recorded ``size``), and the oldest /
|
||||||
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||||
|
Timestamps are compared as parsed ``datetime`` objects so mixed
|
||||||
|
timezone formats (``Z`` vs ``+00:00``) order correctly.
|
||||||
"""
|
"""
|
||||||
files = self._data.get("files", {}) if self._data else {}
|
files = self._data.get("files", {}) if self._data else {}
|
||||||
count = len(files)
|
count = len(files)
|
||||||
total_size = 0
|
total_size = 0
|
||||||
oldest: str | None = None
|
oldest_dt: datetime | None = None
|
||||||
newest: str | None = None
|
newest_dt: datetime | None = None
|
||||||
|
oldest_str: str | None = None
|
||||||
|
newest_str: str | None = None
|
||||||
for entry in files.values():
|
for entry in files.values():
|
||||||
size = entry.get("size")
|
size = entry.get("size")
|
||||||
if isinstance(size, int):
|
if isinstance(size, int):
|
||||||
total_size += size
|
total_size += size
|
||||||
cached_at = entry.get("cached_at")
|
cached_at = entry.get("cached_at")
|
||||||
if cached_at:
|
dt = _parse_iso(cached_at)
|
||||||
if oldest is None or cached_at < oldest:
|
if dt is None or not cached_at:
|
||||||
oldest = cached_at
|
continue
|
||||||
if newest is None or cached_at > newest:
|
if dt.tzinfo is None:
|
||||||
newest = cached_at
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
if oldest_dt is None or dt < oldest_dt:
|
||||||
|
oldest_dt, oldest_str = dt, cached_at
|
||||||
|
if newest_dt is None or dt > newest_dt:
|
||||||
|
newest_dt, newest_str = dt, cached_at
|
||||||
return {
|
return {
|
||||||
"count": count,
|
"count": count,
|
||||||
"total_size_bytes": total_size,
|
"total_size_bytes": total_size,
|
||||||
"oldest": oldest,
|
"oldest": oldest_str,
|
||||||
"newest": newest,
|
"newest": newest_str,
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,20 +2,35 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Telegram constants
|
# Telegram constants
|
||||||
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
||||||
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
|
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
|
||||||
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
|
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
|
||||||
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
|
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000
|
||||||
|
# Telegram message-text limit (sendMessage) and caption limit
|
||||||
|
# (sendPhoto/sendVideo/sendDocument/first item of sendMediaGroup).
|
||||||
|
TELEGRAM_MAX_TEXT_LENGTH: Final = 4096
|
||||||
|
TELEGRAM_MAX_CAPTION_LENGTH: Final = 1024
|
||||||
|
|
||||||
# Generic UUID pattern for asset IDs
|
# Strict canonical-UUID pattern (8-4-4-4-12) for asset IDs. The previous
|
||||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
# loose ``[a-f0-9-]{36}`` matched 36 hyphens / arbitrary digit groupings,
|
||||||
|
# which could collide across providers when used as a cache key.
|
||||||
|
_ASSET_ID_PATTERN = re.compile(
|
||||||
|
r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
# Cache key: "host:uuid" or bare "uuid"
|
# Cache key: "host:uuid" or bare "uuid"
|
||||||
_ASSET_CACHE_KEY_PATTERN = re.compile(r"^(?:[^:]+:)?[a-f0-9-]{36}$")
|
_ASSET_CACHE_KEY_PATTERN = re.compile(
|
||||||
|
r"^(?:[^:]+:)?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
|
# URL patterns to extract asset IDs (generic enough for Immich-style URLs)
|
||||||
_ASSET_ID_URL_PATTERNS = [
|
_ASSET_ID_URL_PATTERNS = [
|
||||||
@@ -162,5 +177,10 @@ def check_photo_limits(
|
|||||||
return False, None, width, height
|
return False, None, width, height
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return False, None, None, None
|
return False, None, None, None
|
||||||
except Exception:
|
except (OSError, ValueError, MemoryError) as exc:
|
||||||
|
# PIL surfaces ``UnidentifiedImageError`` (subclass of OSError),
|
||||||
|
# truncated-image / decompression-bomb errors here. Log so a
|
||||||
|
# corrupt asset isn't silently passed to Telegram and rejected
|
||||||
|
# downstream with a less actionable error.
|
||||||
|
_LOGGER.warning("check_photo_limits: failed to inspect image (%d bytes): %s", len(data), exc)
|
||||||
return False, None, None, None
|
return False, None, None, None
|
||||||
|
|||||||
@@ -7,37 +7,29 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..ssrf import UnsafeURLError, avalidate_outbound_url
|
from ..http_base import HttpProviderClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
|
||||||
|
|
||||||
|
class WebhookClient(HttpProviderClient):
|
||||||
|
"""Send JSON payloads to a webhook URL.
|
||||||
|
|
||||||
class WebhookClient:
|
The URL is SSRF-validated on every send (defense-in-depth: re-validating
|
||||||
"""Send JSON payloads to a webhook URL."""
|
catches DNS rebinding between calls and a misconfigured target). Headers
|
||||||
|
pass through :func:`safe_headers` so a target config can't inject
|
||||||
|
framing/hop-by-hop headers like ``Host`` or ``Transfer-Encoding``.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession, url: str, headers: dict[str, str] | None = None) -> None:
|
def __init__(
|
||||||
self._session = session
|
self,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(session, provider_name="webhook")
|
||||||
self._url = url
|
self._url = url
|
||||||
self._headers = headers or {}
|
self._extra_headers = headers or {}
|
||||||
|
|
||||||
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
try:
|
return await self.request("POST", self._url, json=payload, headers=self._extra_headers)
|
||||||
await avalidate_outbound_url(self._url)
|
|
||||||
except UnsafeURLError as err:
|
|
||||||
return {"success": False, "error": f"Unsafe URL: {err}"}
|
|
||||||
try:
|
|
||||||
async with self._session.post(
|
|
||||||
self._url,
|
|
||||||
json=payload,
|
|
||||||
headers={"Content-Type": "application/json", **self._headers},
|
|
||||||
timeout=_DEFAULT_TIMEOUT,
|
|
||||||
allow_redirects=False,
|
|
||||||
) as response:
|
|
||||||
if 200 <= response.status < 300:
|
|
||||||
return {"success": True, "status_code": response.status}
|
|
||||||
body = await response.text()
|
|
||||||
return {"success": False, "error": f"HTTP {response.status}", "body": body[:200]}
|
|
||||||
except aiohttp.ClientError as err:
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
@@ -21,6 +21,14 @@ class ServiceProviderType(str, Enum):
|
|||||||
NUT = "nut"
|
NUT = "nut"
|
||||||
GOOGLE_PHOTOS = "google_photos"
|
GOOGLE_PHOTOS = "google_photos"
|
||||||
WEBHOOK = "webhook"
|
WEBHOOK = "webhook"
|
||||||
|
HOME_ASSISTANT = "home_assistant"
|
||||||
|
BRIDGE_SELF = "bridge_self"
|
||||||
|
|
||||||
|
|
||||||
|
# Callback signature for push-style providers: a coroutine that accepts a
|
||||||
|
# parsed ServiceEvent and is expected to enqueue it for dispatch. Returning
|
||||||
|
# None keeps the contract narrow — error handling stays inside the callback.
|
||||||
|
EventEmitCallback = Callable[["ServiceEvent"], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
class ServiceProvider(ABC):
|
class ServiceProvider(ABC):
|
||||||
@@ -28,10 +36,27 @@ class ServiceProvider(ABC):
|
|||||||
|
|
||||||
A service provider connects to an external service (e.g., Immich photo server)
|
A service provider connects to an external service (e.g., Immich photo server)
|
||||||
and can poll for changes, producing generic ServiceEvent objects.
|
and can poll for changes, producing generic ServiceEvent objects.
|
||||||
|
|
||||||
|
Two ingest modes coexist on this base class:
|
||||||
|
|
||||||
|
* Polling providers (Immich, NUT, Google Photos, Scheduler) implement
|
||||||
|
:meth:`poll` and leave :attr:`supports_subscription` False.
|
||||||
|
* Webhook providers (Gitea, Planka, generic Webhook) no-op :meth:`poll`
|
||||||
|
and receive events out-of-band via ``api/webhooks.py``.
|
||||||
|
* Subscription providers (Home Assistant) flip
|
||||||
|
:attr:`supports_subscription` to True and implement :meth:`subscribe`
|
||||||
|
to run a long-lived task that pushes events through an
|
||||||
|
``emit`` callback. They typically no-op :meth:`poll`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider_type: ServiceProviderType
|
provider_type: ServiceProviderType
|
||||||
|
|
||||||
|
# When True, the lifecycle layer (server-side subscription manager) starts
|
||||||
|
# a long-running task that calls :meth:`subscribe` instead of registering
|
||||||
|
# this provider with the polling scheduler. Default False keeps the
|
||||||
|
# legacy poll/webhook flow intact for every existing provider.
|
||||||
|
supports_subscription: bool = False
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Connect to the service and verify connectivity.
|
"""Connect to the service and verify connectivity.
|
||||||
@@ -59,6 +84,27 @@ class ServiceProvider(ABC):
|
|||||||
Tuple of (list of events detected, updated state dict).
|
Tuple of (list of events detected, updated state dict).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def subscribe(self, emit: EventEmitCallback) -> None:
|
||||||
|
"""Run a long-lived subscription that calls ``emit`` for each event.
|
||||||
|
|
||||||
|
Override on providers with :attr:`supports_subscription` = True. The
|
||||||
|
implementation is expected to:
|
||||||
|
|
||||||
|
* Loop until cancelled (the subscription manager uses
|
||||||
|
:func:`asyncio.Task.cancel` on shutdown).
|
||||||
|
* Handle its own reconnect with exponential backoff — never propagate
|
||||||
|
transient network errors to the caller.
|
||||||
|
* Pass parsed :class:`ServiceEvent` instances to ``emit`` for
|
||||||
|
enqueueing/dispatch. The callback is responsible for routing.
|
||||||
|
|
||||||
|
The default implementation raises :class:`NotImplementedError` so
|
||||||
|
accidental wiring of a polling provider into the subscription manager
|
||||||
|
fails loudly rather than silently doing nothing.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} does not support subscription-based ingest"
|
||||||
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
"""Return the template variables this provider makes available."""
|
"""Return the template variables this provider makes available."""
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Bridge self-monitoring service provider.
|
||||||
|
|
||||||
|
Unlike external providers (Immich, Gitea, NUT, ...), the ``bridge_self``
|
||||||
|
provider does not connect to any remote service. Its sole purpose is to
|
||||||
|
give operators a configurable surface (thresholds + notification slots
|
||||||
|
+ trackers + targets) for events that the bridge itself emits when its
|
||||||
|
internal subsystems fail.
|
||||||
|
|
||||||
|
Three failure conditions are surfaced as :class:`ServiceEvent` instances
|
||||||
|
through the same dispatch pipeline that all other providers use:
|
||||||
|
|
||||||
|
* ``bridge_self_poll_failures`` — N consecutive poll failures for
|
||||||
|
any tracker exceed the configured threshold.
|
||||||
|
* ``bridge_self_deferred_backlog`` — pending ``deferred_dispatch`` row
|
||||||
|
count crosses the configured threshold.
|
||||||
|
* ``bridge_self_target_failures`` — N consecutive 5xx / network failures
|
||||||
|
for a single notification target.
|
||||||
|
|
||||||
|
Events are constructed by ``services/bridge_self.py`` on the server side
|
||||||
|
(it owns DB access for looking up the bridge_self provider per user)
|
||||||
|
and then fed into ``dispatch_provider_event`` like any other event.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from notify_bridge_core.providers.base import ServiceProviderType
|
||||||
|
from notify_bridge_core.templates.variables import registry
|
||||||
|
|
||||||
|
from .event_parser import build_event
|
||||||
|
from .provider import BRIDGE_SELF_VARIABLES, BridgeSelfServiceProvider
|
||||||
|
|
||||||
|
# Register variables so the validator and template-vars API see them.
|
||||||
|
registry.register_provider_variables(
|
||||||
|
ServiceProviderType.BRIDGE_SELF, BRIDGE_SELF_VARIABLES,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BRIDGE_SELF_VARIABLES",
|
||||||
|
"BridgeSelfServiceProvider",
|
||||||
|
"build_event",
|
||||||
|
]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Bridge self-monitoring event parser.
|
||||||
|
|
||||||
|
The bridge generates these events from internal subsystems (watcher,
|
||||||
|
scheduler, dispatcher) — the parser turns a flat payload dict into the
|
||||||
|
generic :class:`ServiceEvent` shape that the rest of the dispatch
|
||||||
|
pipeline expects.
|
||||||
|
|
||||||
|
Payload shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"failure_type": "poll_failures" | "deferred_backlog" | "target_failures",
|
||||||
|
"subject_id": int, # tracker_id, target_id, or 0
|
||||||
|
"subject_name": str,
|
||||||
|
"count": int, # consecutive failures or pending count
|
||||||
|
"threshold": int,
|
||||||
|
"last_error": str, # may be empty
|
||||||
|
"details": dict[str, Any], # extra context
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||||
|
from notify_bridge_core.providers.base import ServiceProviderType
|
||||||
|
|
||||||
|
|
||||||
|
# Defensive cap on the persisted error message; very long tracebacks would
|
||||||
|
# bloat the EventLog details JSON column otherwise.
|
||||||
|
_MAX_ERROR_LEN = 1000
|
||||||
|
|
||||||
|
|
||||||
|
_FAILURE_TYPE_TO_EVENT: dict[str, EventType] = {
|
||||||
|
"poll_failures": EventType.BRIDGE_SELF_POLL_FAILURES,
|
||||||
|
"deferred_backlog": EventType.BRIDGE_SELF_DEFERRED_BACKLOG,
|
||||||
|
"target_failures": EventType.BRIDGE_SELF_TARGET_FAILURES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_event(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
provider_name: str = "Bridge Self-Monitoring",
|
||||||
|
timestamp: datetime | None = None,
|
||||||
|
) -> ServiceEvent | None:
|
||||||
|
"""Convert a self-monitoring payload dict into a ServiceEvent.
|
||||||
|
|
||||||
|
Returns None for malformed payloads (unknown failure_type or missing
|
||||||
|
keys) — the caller drops without raising so a misbehaving emitter
|
||||||
|
can never tip over the dispatch pipeline.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
failure_type = payload.get("failure_type")
|
||||||
|
event_type = _FAILURE_TYPE_TO_EVENT.get(str(failure_type) if failure_type else "")
|
||||||
|
if event_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
subject_id = int(payload.get("subject_id") or 0)
|
||||||
|
subject_name = str(payload.get("subject_name") or "")
|
||||||
|
count = int(payload.get("count") or 0)
|
||||||
|
threshold = int(payload.get("threshold") or 0)
|
||||||
|
last_error = str(payload.get("last_error") or "")[:_MAX_ERROR_LEN]
|
||||||
|
details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
|
||||||
|
|
||||||
|
when = timestamp or datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
return ServiceEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
provider_name=provider_name,
|
||||||
|
# ``collection_id`` / ``collection_name`` are required fields on
|
||||||
|
# ServiceEvent; we use the subject so quiet-hours / dedupe logic
|
||||||
|
# treats different subjects as distinct streams.
|
||||||
|
collection_id=str(subject_id),
|
||||||
|
collection_name=subject_name or str(failure_type),
|
||||||
|
timestamp=when,
|
||||||
|
extra={
|
||||||
|
"failure_type": str(failure_type),
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"subject_name": subject_name,
|
||||||
|
"count": count,
|
||||||
|
"threshold": threshold,
|
||||||
|
"last_error": last_error,
|
||||||
|
"details": dict(details),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Bridge self-monitoring service provider — emits internal-failure events.
|
||||||
|
|
||||||
|
This is a passive provider: it does not connect to anything, never polls,
|
||||||
|
and never subscribes. It exists so the rest of the bridge's CRUD / config /
|
||||||
|
template / target plumbing has a single ``ServiceProvider`` to attach
|
||||||
|
self-monitoring trackers and notification slots to.
|
||||||
|
|
||||||
|
Events are constructed by the server-side helper
|
||||||
|
``services/bridge_self.emit_bridge_self_event`` and pushed into
|
||||||
|
``dispatch_provider_event`` directly — the provider itself is not asked
|
||||||
|
to produce events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
from notify_bridge_core.providers.base import (
|
||||||
|
ServiceProvider,
|
||||||
|
ServiceProviderType,
|
||||||
|
)
|
||||||
|
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration keys recognised on the bridge_self provider's ``config`` JSON.
|
||||||
|
DEFAULT_POLL_FAILURE_THRESHOLD = 3
|
||||||
|
DEFAULT_DEFERRED_BACKLOG_THRESHOLD = 100
|
||||||
|
DEFAULT_TARGET_FAILURE_THRESHOLD = 5
|
||||||
|
|
||||||
|
|
||||||
|
# Template variables exposed to bridge_self templates.
|
||||||
|
BRIDGE_SELF_VARIABLES: list[TemplateVariableDefinition] = [
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="failure_type",
|
||||||
|
type="string",
|
||||||
|
description="Which self-monitoring condition fired",
|
||||||
|
example="poll_failures",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="subject_id",
|
||||||
|
type="int",
|
||||||
|
description="ID of the affected entity (tracker_id, target_id, or 0)",
|
||||||
|
example="42",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="subject_name",
|
||||||
|
type="string",
|
||||||
|
description="Human-readable name of the affected entity",
|
||||||
|
example="My Immich Tracker",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="count",
|
||||||
|
type="int",
|
||||||
|
description="Consecutive failure count or current backlog size",
|
||||||
|
example="3",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="threshold",
|
||||||
|
type="int",
|
||||||
|
description="Configured threshold that was crossed",
|
||||||
|
example="3",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="last_error",
|
||||||
|
type="string",
|
||||||
|
description="Last underlying error message (truncated)",
|
||||||
|
example="Connection refused",
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="details",
|
||||||
|
type="dict",
|
||||||
|
description="Extra structured context for the event",
|
||||||
|
example='{"provider_id": 7}',
|
||||||
|
provider_type=ServiceProviderType.BRIDGE_SELF,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeSelfServiceProvider(ServiceProvider):
|
||||||
|
"""Passive provider — exposes nothing remote, holds only thresholds.
|
||||||
|
|
||||||
|
Polling is a no-op and ``connect`` always succeeds; the bridge itself
|
||||||
|
is what generates events for this provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider_type = ServiceProviderType.BRIDGE_SELF
|
||||||
|
supports_subscription = False
|
||||||
|
|
||||||
|
def __init__(self, name: str = "Bridge Self-Monitoring") -> None:
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def poll(
|
||||||
|
self,
|
||||||
|
collection_ids: list[str],
|
||||||
|
tracker_state: dict[str, Any],
|
||||||
|
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||||
|
# No external service to poll. Returning empty keeps the contract
|
||||||
|
# so accidental scheduling no-ops cleanly.
|
||||||
|
return [], tracker_state
|
||||||
|
|
||||||
|
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
|
return list(BRIDGE_SELF_VARIABLES)
|
||||||
|
|
||||||
|
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"poll_failure_threshold": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": DEFAULT_POLL_FAILURE_THRESHOLD,
|
||||||
|
"description": "Consecutive tracker poll failures before alerting",
|
||||||
|
},
|
||||||
|
"deferred_backlog_threshold": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": DEFAULT_DEFERRED_BACKLOG_THRESHOLD,
|
||||||
|
"description": "Pending deferred_dispatch rows before alerting",
|
||||||
|
},
|
||||||
|
"target_failure_threshold": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": DEFAULT_TARGET_FAILURE_THRESHOLD,
|
||||||
|
"description": "Consecutive target send failures before alerting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_collections(self) -> list[dict[str, Any]]:
|
||||||
|
# No collection concept — operators don't pick anything for this provider.
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def test_connection(self) -> dict[str, Any]:
|
||||||
|
return {"ok": True, "message": "Bridge self-monitoring is always available"}
|
||||||
@@ -444,6 +444,133 @@ WEBHOOK_CAPABILITIES = ProviderCapabilities(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Home Assistant provider capabilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
HOME_ASSISTANT_CAPABILITIES = ProviderCapabilities(
|
||||||
|
provider_type="home_assistant",
|
||||||
|
display_name="Home Assistant",
|
||||||
|
webhook_based=False,
|
||||||
|
supported_filters=[
|
||||||
|
{
|
||||||
|
"key": "collections",
|
||||||
|
"label": "Entities",
|
||||||
|
"type": "tags",
|
||||||
|
"placeholder": "light.kitchen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "entity_glob",
|
||||||
|
"label": "Entity glob",
|
||||||
|
"type": "tags",
|
||||||
|
"placeholder": "light.*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "domain_allowlist",
|
||||||
|
"label": "Domains",
|
||||||
|
"type": "tags",
|
||||||
|
"placeholder": "light, binary_sensor",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notification_slots=[
|
||||||
|
{"name": "message_ha_state_changed", "description": "Entity state changed"},
|
||||||
|
{"name": "message_ha_automation_triggered", "description": "Automation triggered"},
|
||||||
|
{"name": "message_ha_service_called", "description": "HA service called"},
|
||||||
|
{"name": "message_ha_event_fired", "description": "Other HA event fired"},
|
||||||
|
],
|
||||||
|
events=[
|
||||||
|
{"name": "ha_state_changed", "description": "Entity state changed"},
|
||||||
|
{"name": "ha_automation_triggered", "description": "Automation triggered"},
|
||||||
|
{"name": "ha_service_called", "description": "HA service called"},
|
||||||
|
{"name": "ha_event_fired", "description": "Other HA event fired (catch-all)"},
|
||||||
|
],
|
||||||
|
command_slots=[
|
||||||
|
# Response templates
|
||||||
|
{"name": "start", "description": "/start greeting message"},
|
||||||
|
{"name": "help", "description": "/help command listing"},
|
||||||
|
{"name": "status", "description": "/status connection summary"},
|
||||||
|
{"name": "entities", "description": "/entities matching glob"},
|
||||||
|
{"name": "state", "description": "/state single-entity drill-down"},
|
||||||
|
{"name": "areas", "description": "/areas with entity counts"},
|
||||||
|
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||||
|
{"name": "no_results", "description": "Empty results fallback"},
|
||||||
|
# Description slots
|
||||||
|
{"name": "desc_help", "description": "Menu description for /help"},
|
||||||
|
{"name": "desc_status", "description": "Menu description for /status"},
|
||||||
|
{"name": "desc_entities", "description": "Menu description for /entities"},
|
||||||
|
{"name": "desc_state", "description": "Menu description for /state"},
|
||||||
|
{"name": "desc_areas", "description": "Menu description for /areas"},
|
||||||
|
# Usage examples
|
||||||
|
{"name": "usage_entities", "description": "Usage example for /entities"},
|
||||||
|
{"name": "usage_state", "description": "Usage example for /state"},
|
||||||
|
],
|
||||||
|
commands=[
|
||||||
|
{"name": "status", "description": "Show connection status"},
|
||||||
|
{"name": "entities", "description": "List entities (optional glob)"},
|
||||||
|
{"name": "state", "description": "Show state for one entity"},
|
||||||
|
{"name": "areas", "description": "List HA areas with entity counts"},
|
||||||
|
{"name": "help", "description": "Show commands"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bridge self-monitoring capabilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BRIDGE_SELF_CAPABILITIES = ProviderCapabilities(
|
||||||
|
provider_type="bridge_self",
|
||||||
|
display_name="Bridge Self-Monitoring",
|
||||||
|
webhook_based=False,
|
||||||
|
supported_filters=[],
|
||||||
|
notification_slots=[
|
||||||
|
{
|
||||||
|
"name": "message_bridge_self_poll_failures",
|
||||||
|
"description": "Tracker poll failures crossed threshold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message_bridge_self_deferred_backlog",
|
||||||
|
"description": "Deferred dispatch backlog crossed threshold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message_bridge_self_target_failures",
|
||||||
|
"description": "Target send failures crossed threshold",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events=[
|
||||||
|
{"name": "bridge_self_poll_failures", "description": "Tracker poll failures"},
|
||||||
|
{"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"},
|
||||||
|
{"name": "bridge_self_target_failures", "description": "Target send failures"},
|
||||||
|
],
|
||||||
|
command_slots=[
|
||||||
|
# Response templates
|
||||||
|
{"name": "start", "description": "/start greeting message"},
|
||||||
|
{"name": "help", "description": "/help command listing"},
|
||||||
|
{"name": "status", "description": "/status full counter snapshot"},
|
||||||
|
{"name": "thresholds", "description": "/thresholds configured alert thresholds"},
|
||||||
|
{"name": "reset", "description": "/reset manual counter reset"},
|
||||||
|
{"name": "health", "description": "/health terse one-line summary"},
|
||||||
|
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||||
|
{"name": "no_results", "description": "Empty results fallback"},
|
||||||
|
# Description slots
|
||||||
|
{"name": "desc_help", "description": "Menu description for /help"},
|
||||||
|
{"name": "desc_status", "description": "Menu description for /status"},
|
||||||
|
{"name": "desc_thresholds", "description": "Menu description for /thresholds"},
|
||||||
|
{"name": "desc_reset", "description": "Menu description for /reset"},
|
||||||
|
{"name": "desc_health", "description": "Menu description for /health"},
|
||||||
|
# Usage examples
|
||||||
|
{"name": "usage_reset", "description": "Usage example for /reset"},
|
||||||
|
],
|
||||||
|
commands=[
|
||||||
|
{"name": "status", "description": "Show current bridge health counters"},
|
||||||
|
{"name": "thresholds", "description": "Show configured alert thresholds"},
|
||||||
|
{"name": "reset", "description": "Manually reset a failure counter"},
|
||||||
|
{"name": "health", "description": "Terse one-line health summary"},
|
||||||
|
{"name": "help", "description": "Show commands"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Registry
|
# Registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -456,6 +583,8 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
|||||||
"nut": NUT_CAPABILITIES,
|
"nut": NUT_CAPABILITIES,
|
||||||
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
||||||
"webhook": WEBHOOK_CAPABILITIES,
|
"webhook": WEBHOOK_CAPABILITIES,
|
||||||
|
"home_assistant": HOME_ASSISTANT_CAPABILITIES,
|
||||||
|
"bridge_self": BRIDGE_SELF_CAPABILITIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user