Compare commits

..

4 Commits

Author SHA1 Message Date
alexei.dolgolyov dfd7329177 chore: release v0.8.0
Release / release (push) Successful in 1m55s
2026-05-12 03:01:06 +03:00
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00
alexei.dolgolyov bb5afcc222 docs: expand README with all providers, targets, bot commands, and smart actions 2026-05-11 22:21:51 +03:00
alexei.dolgolyov 4335036c22 docs: sync README deploy section with actual env vars
Fix CORS default (was incorrectly listed as `*`, which is rejected on
startup) and document the env vars exposed by config.py and
docker-compose.yml — proxy/SSRF, auth, logging, retention, and
integration settings. Sync the Docker Compose example with the
hardened compose file at the repo root.
2026-05-11 21:50:31 +03:00
53 changed files with 5801 additions and 317 deletions
+142 -11
View File
@@ -2,20 +2,21 @@
A generic bridge between service providers and notification targets.
Notify Bridge monitors services (like Immich photo servers) for changes and dispatches
notifications to configurable targets (Telegram, webhooks) using customizable templates.
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks,
and internal scheduler) for changes and dispatches notifications to configurable targets
(Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
## Architecture
- **Service Providers** — Connectors to external services (Immich, more coming)
- **Service Providers** — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
- **Trackers** — Monitor specific collections within a provider for changes
- **Tracking Configs** — Define what events to watch for and scheduling rules
- **Notification Targets** — Where to send notifications (Telegram chats, webhook URLs)
- **Notification Targets** — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
- **Template Configs** — Jinja2 templates that format notifications per provider type
## Project Structure
```
```text
packages/
core/ — Shared library: providers, models, notifications, templates
server/ — FastAPI REST server with SQLite database
@@ -31,6 +32,7 @@ docker run -d \
-p 8420:8420 \
-v notify-bridge-data:/data \
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
```
@@ -38,12 +40,59 @@ Then open `http://localhost:8420` in your browser.
### Environment Variables
Core settings (all prefixed with `NOTIFY_BRIDGE_`):
| Variable | Required | Default | Description |
| -------- | -------- | ------- | ----------- |
| `NOTIFY_BRIDGE_SECRET_KEY` | Yes | — | Secret key for JWT tokens (min 32 chars) |
| `NOTIFY_BRIDGE_PORT` | No | `8420` | Server listen port |
| `NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS` | No | `*` | Comma-separated allowed CORS origins |
| `NOTIFY_BRIDGE_DEBUG` | No | `false` | Enable debug logging |
| `SECRET_KEY` | Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
| `CORS_ALLOWED_ORIGINS` | Recommended | `http://localhost:5175` | Comma-separated browser origins. Wildcard `*` is **rejected** because credentials are enabled. Set this to the URL you load the UI from. |
| `DATA_DIR` | No | `/data` (in Docker) | Directory for SQLite DB, backups, and caches. Mount a volume here. |
| `DATABASE_URL` | No | `sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db` | Override DB connection string. |
| `HOST` | No | `0.0.0.0` | Bind address. |
| `PORT` | No | `8420` | Server listen port. |
| `DEBUG` | No | `false` | Enable debug logging. |
Reverse proxy / network:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy IPs whose `X-Forwarded-For` / `X-Forwarded-Proto` headers are honored. Set to your reverse proxy IP (e.g. `172.17.0.1` for the default Docker bridge). Use `*` only when the container is not directly internet-reachable. |
| `EXTERNAL_URL` | — | Public base URL (e.g. `https://notify.example.com`). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
| `ALLOW_PRIVATE_URLS` | unset | Set to `1` to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). **Do not enable on a publicly exposed instance.** |
Auth & tokens:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | Lifetime of access JWTs. |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `30` | Lifetime of refresh tokens. |
| `JWT_ISSUER` | `notify-bridge` | `iss` claim. |
| `JWT_AUDIENCE` | `notify-bridge-api` | `aud` claim. |
Logging (all are also live-editable in the Settings page, except `log_format`):
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `LOG_LEVEL` | `INFO` | Root level: `DEBUG` / `INFO` / `WARNING` / `ERROR`. |
| `LOG_FORMAT` | `text` | `text` or `json`. Switching requires a restart. |
| `LOG_LEVELS` | — | Per-module overrides, e.g. `notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO`. |
Retention & maintenance:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `EVENT_LOG_RETENTION_DAYS` | `30` | Days of `event_log` history to keep. `0` disables the retention job. |
| `PRE_MIGRATE_SNAPSHOT_KEEP` | `5` | Number of pre-migration DB snapshots to keep in `<DATA_DIR>/backups/`. `0` disables snapshotting. |
| `GRACEFUL_SHUTDOWN_SECONDS` | `60` | Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
Integrations & misc:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `TELEGRAM_WEBHOOK_SECRET` | — | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
| `TIMEZONE` | `UTC` | IANA timezone (e.g. `Europe/Warsaw`) used by the scheduler. Also settable from the Settings page. |
| `STATIC_DIR` | `/app/static` (in Docker) | Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
| `SUPERVISED` | auto-detect | Set to `1` to tell the backup endpoint that an external supervisor will restart the process. |
### Docker Compose
@@ -58,12 +107,50 @@ services:
volumes:
- notify-bridge-data:/data
environment:
- NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32-characters
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
# Comma-separated list of allowed browser origins. Wildcard `*` is
# rejected on startup because credentials are enabled.
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
# docker bridge, or `*` only if the container is NOT reachable from the
# public internet).
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
# enable on a publicly exposed instance.
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
# Use /api/ready (not /api/health) so the container is only reported
# healthy after migrations and the scheduler finish booting.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
cpus: 1.0
pids_limit: 256
volumes:
notify-bridge-data:
```
A ready-to-use `docker-compose.yml` lives at the repo root.
### Health & Readiness
- `GET /api/health` — process is up. Use for liveness probes.
- `GET /api/ready` — migrations + scheduler have booted. Use for readiness probes and Docker `HEALTHCHECK` (as the compose example above does).
## Quick Start (Development)
```bash
@@ -81,4 +168,48 @@ npm run dev
## Supported Providers
- **Immich** — Photo/video server with album change detection
- **Immich** — Photo/video server with album change detection (polling)
- **Gitea** — Git server with push / issue / PR / release events (webhook)
- **Planka** — Kanban board with card / list / board events (webhook)
- **NUT** — Network UPS Tools for battery / power events (polling)
- **Google Photos** — Album change detection (polling)
- **Generic Webhook** — Catch arbitrary JSON payloads and route them via templates (webhook)
- **Scheduler** — Internal provider for time-based scheduled messages
## Supported Notification Targets
- **Telegram** — Bot API with rich formatting, media groups, and inline commands
- **Discord** — Webhook-based delivery with embeds
- **Slack** — Incoming webhooks with Block Kit formatting
- **Matrix** — Homeserver delivery with HTML formatting
- **ntfy** — Self-hostable push notifications
- **Email** — SMTP with HTML / plain-text templates
- **Generic Webhook** — POST custom JSON payloads to any URL
## Bot Commands
Telegram bots can serve interactive commands per provider. All commands use
Jinja2 templates that you can customize from the **Command Templates** page.
| Provider | Commands |
| -------- | -------- |
| Immich | `/status` `/albums` `/events` `/summary` `/latest` `/memory` `/random` `/search` `/find` `/person` `/place` `/favorites` `/people` `/help` |
| Gitea | `/status` `/repos` `/issues` `/prs` `/commits` `/help` |
| Planka | `/status` `/boards` `/cards` `/lists` `/help` |
| NUT | `/status` `/devices` `/battery` `/help` |
| Google Photos | `/status` `/albums` `/latest` `/search` `/random` `/help` |
| Generic Webhook | `/status` `/help` |
Every provider also responds to `/start`, and rate-limit / empty-result
fallback messages are templated as well.
## Smart Actions
Beyond notifications, providers can run **actions** against the source service.
Currently implemented:
- **Immich — Auto-Organize** — Automatically sort newly-detected assets into
albums based on configurable rules. Each rule combines criteria (people in
the photo, search query, favorites, date range) with a target album, and can
create the album if it doesn't exist. Supports dry-run mode for previewing
what would move before committing.
+27 -11
View File
@@ -1,14 +1,31 @@
# v0.7.2 (2026-05-11)
# v0.8.0 (2026-05-12)
## Features
## User-facing changes
- Redesign settings/common with Aurora cassettes — refreshed identity, logging, Telegram, and cache-ledger sections with the new glass/cassette UI ([6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9))
- Group targets by bot in the targets view and redesign backup settings ([a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad))
- Add `/status` command handler for webhook providers ([bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928))
### Features
## Bug Fixes
- **Quiet hours now defer notifications instead of dropping them.** Events that arrive during a tracker's quiet window are stored on disk and re-fired at the window end. Asset events for the same `(link, event_type, collection)` coalesce so a flurry of adds/removes during the night collapses into a single morning notification ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Upstream release check.** New "Release Cassette" in Settings polls a configurable Gitea or GitHub repo on a schedule and surfaces the latest tag in the UI so you know when a newer Notify Bridge is available. Pre-release filtering and interval are operator-configurable; the install ships pointed at this repo's own upstream ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- **Frontend polish across the board.** New `MetaStrip` component, expanded `EventDetailModal`, and i18n additions land alongside cohesive Aurora-glass styling tweaks on most management pages — providers, targets, bots, trackers, command and notification templates, users, actions, layout, and dashboard ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Stop event-log flicker on pagination ([87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c))
### Documentation
- README rewritten to cover every supported provider, target type, bot command, and smart action — including the deploy / env-var matrix ([bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc), [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036))
---
## Development / Internal
### Architecture
- New `deferred_dispatch` table with two migrations: an `ON DELETE SET NULL` FK rebuild on `event_log_id` (so the daily event-log retention sweep no longer deadlocks against pending defers), and a partial unique index on `(link_id, collection_id, event_type) WHERE status='pending'` to make coalescing race-safe under SQLite's serializable writes ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Drain scheduler with three layers: a one-shot APScheduler `date` job per window-end (idempotent, minute-bucketed), a 5-minute periodic catch-up scan as safety net for misfire-grace overflow and process-restart gaps, and `load_pending_drain_jobs` to re-arm scheduled drains on boot ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Release-check provider abstraction (`packages/core/.../release/`) with Gitea and GitHub adapters, SSRF-safe outbound URL validation, a registry/factory, and a server-side scheduler probe with cached state and on-settings-change cache invalidation ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
- Version resolution helper (`packages/server/.../version.py`) that returns the max of installed-package metadata vs source `pyproject.toml` — fixes the long-running editable-install bug where bumping the version without reinstalling kept the old number visible in the UI ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
### Tests
- New test suites: `test_deferred_dispatch.py` (drain + coalescing + retention interaction), `test_release_provider.py` (Gitea and GitHub adapter parsing and error paths), and `test_release_service.py` (scheduler-level caching and settings invalidation) ([ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2))
---
@@ -17,9 +34,8 @@
| Hash | Message | Author |
|------|---------|--------|
| [6229bf9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6229bf9) | feat(frontend): redesign settings/common with Aurora cassettes | alexei.dolgolyov |
| [a666bad](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a666bad) | feat(frontend): group targets by bot, redesign backup settings | alexei.dolgolyov |
| [bede928](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bede928) | feat(server): add /status command handler for webhook providers | alexei.dolgolyov |
| [87cb33c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/87cb33c) | fix(frontend): stop event-log flicker on pagination | alexei.dolgolyov |
| [ba199f2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ba199f2) | feat: deferred dispatch, release-check provider, settings polish | alexei.dolgolyov |
| [bb5afcc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bb5afcc) | docs: expand README with all providers, targets, bot commands, and smart actions | alexei.dolgolyov |
| [4335036](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4335036) | docs: sync README deploy section with actual env vars | alexei.dolgolyov |
</details>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.7.1",
"version": "0.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.7.1",
"version": "0.8.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.7.2",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+40
View File
@@ -377,6 +377,46 @@ button:focus-visible, a:focus-visible {
.stagger-children > * {
animation: aurora-rise 0.55s cubic-bezier(.2,.7,.2,1) both;
}
/* === List stack — used by list pages (providers, trackers, configs, etc.) ===
Full-bleed rows that stretch to the main column width. Pair with .list-row
inside each Card for the 3-zone layout (identity · meta-strip · actions). */
.list-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.list-row {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
}
.list-row__identity {
min-width: 0;
flex: 0 0 auto;
max-width: 28rem;
}
@media (max-width: 1023px) {
.list-row__identity { flex: 1 1 auto; }
}
.list-row__actions {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* Secondary text under the name — visible only when meta-strip is hidden
(i.e. on narrow screens). On lg+ the meta-strip takes over. */
.list-row__secondary {
display: block;
}
@media (min-width: 1024px) {
.list-row__secondary { display: none; }
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
@@ -12,6 +12,13 @@
}
let { event, onclose }: Props = $props();
// Retain the last non-null event so the modal body stays populated
// while the close transition plays after the parent clears `event`.
let displayEvent = $state<EventLog | null>(null);
$effect(() => {
if (event) displayEvent = event;
});
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
@@ -21,6 +28,44 @@
}
}
/** Humanize a duration in seconds into ``Xd Yh`` / ``Xh Ym`` / ``Xm`` / ``Xs``.
*
* Used by the deferred-dispatch lifecycle banner to render
* ``deferred_for_seconds`` ("held for 8h 23m") rather than an opaque
* integer that the user has to mentally divide. Keeps two units so
* the magnitude reads correctly across hours-long quiet windows
* without becoming noisy for short ones. */
function humanDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '';
if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
}
/** Render an absolute ISO timestamp as a future-relative string.
*
* "in 8h 23m" / "in 12m". Returns an empty string for past times — the
* deferred-until banner shouldn't show a relative offset once the
* window has already ended (a follow-up event_log row marks delivery).
*/
function timeFromNow(iso: string | undefined): string {
if (!iso) return '';
try {
const target = new Date(iso).getTime();
const diff = Math.floor((target - Date.now()) / 1000);
if (diff <= 0) return '';
return humanDuration(diff);
} catch {
return '';
}
}
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
if (!issuer) return '';
if (issuer.username) return '@' + issuer.username;
@@ -41,47 +86,130 @@
goto(path);
}
const issuer = $derived(event?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuer = $derived(displayEvent?.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined);
const issuerText = $derived(issuerLabel(issuer));
const isCommand = $derived(event?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(event?.event_type?.startsWith('action_') ?? false);
const isCommand = $derived(displayEvent?.event_type?.startsWith('command_') ?? false);
const isAction = $derived(displayEvent?.event_type?.startsWith('action_') ?? false);
const detailsJson = $derived.by(() => {
if (!event?.details) return '';
if (!displayEvent?.details) return '';
try {
return JSON.stringify(event.details, null, 2);
return JSON.stringify(displayEvent.details, null, 2);
} catch {
return String(event.details);
return String(displayEvent.details);
}
});
</script>
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
{#if event}
<Modal open={event !== null} title={displayEvent ? t('events.detailTitle') : ''} {onclose}>
{#if displayEvent}
<div class="event-detail">
<!-- Subject + verb -->
<div class="hero-row">
<MdiIcon name="mdiBell" size={18} />
<div>
<div class="hero-subject">{event.collection_name || event.event_type}</div>
<div class="hero-subject">{displayEvent.collection_name || displayEvent.event_type}</div>
<div class="hero-meta">
<span class="event-type">{event.event_type}</span>
<span class="event-type">{displayEvent.event_type}</span>
<span class="dot">·</span>
<span>{fmtDateTime(event.created_at)}</span>
<span>{fmtDateTime(displayEvent.created_at)}</span>
</div>
</div>
</div>
<!-- Dispatch lifecycle (only when the event went through the
quiet-hours defer path). Rendered ABOVE the provenance grid
because timing of delivery is more interesting than the
bot/tracker names when the event is held back. -->
{#if displayEvent.details?.dispatch_status === 'deferred'}
<section class="lifecycle lifecycle--deferred">
<MdiIcon name="mdiPauseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.heldTitle')}</div>
<div class="lifecycle-detail">
{t('events.lifecycle.heldUntil')}
<b>{fmtDateTime(displayEvent.details.deferred_until ?? '')}</b>
{#if timeFromNow(displayEvent.details.deferred_until)}
<span class="lifecycle-rel">· {t('events.lifecycle.inPrefix')} {timeFromNow(displayEvent.details.deferred_until)}</span>
{/if}
</div>
<div class="lifecycle-hint">{t('events.lifecycle.heldHint')}</div>
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'delivered_after_quiet_hours'}
<section class="lifecycle lifecycle--late">
<MdiIcon name="mdiClockCheckOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.deliveredLateTitle')}</div>
{#if displayEvent.details.deferred_for_seconds != null}
<div class="lifecycle-detail">
{t('events.lifecycle.heldFor')}
<b>{humanDuration(displayEvent.details.deferred_for_seconds)}</b>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_dropped'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiCloseCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.droppedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'deferred_then_failed'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiAlertCircleOutline" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.failedTitle')}</div>
{#if displayEvent.details.reason}
<div class="lifecycle-detail">
{t('events.lifecycle.reason')}:
<code class="lifecycle-reason">{displayEvent.details.reason}</code>
</div>
{/if}
{#if displayEvent.details.original_event_log_id}
<div class="lifecycle-hint">
{t('events.lifecycle.originalEvent')} #{displayEvent.details.original_event_log_id}
</div>
{/if}
</div>
</section>
{:else if displayEvent.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<section class="lifecycle lifecycle--dropped">
<MdiIcon name="mdiVolumeOff" size={18} />
<div class="lifecycle-body">
<div class="lifecycle-title">{t('events.lifecycle.suppressedTitle')}</div>
<div class="lifecycle-hint">{t('events.lifecycle.suppressedHint')}</div>
</div>
</section>
{/if}
<!-- Provenance grid -->
<dl class="provenance">
{#if event.bot_name}
{#if displayEvent.bot_name}
<dt>{t('events.bot')}</dt>
<dd>{event.bot_name}</dd>
<dd>{displayEvent.bot_name}</dd>
{/if}
{#if event.collection_id && isCommand}
{#if displayEvent.collection_id && isCommand}
<dt>{t('events.chat')}</dt>
<dd class="font-mono">{event.collection_id}</dd>
<dd class="font-mono">{displayEvent.collection_id}</dd>
{/if}
{#if issuerText}
<dt>{t('events.issuer')}</dt>
@@ -90,56 +218,56 @@
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
</dd>
{/if}
{#if event.command_tracker_name}
{#if displayEvent.command_tracker_name}
<dt>{t('events.commandTracker')}</dt>
<dd>{event.command_tracker_name}</dd>
<dd>{displayEvent.command_tracker_name}</dd>
{/if}
{#if event.tracker_name}
{#if displayEvent.tracker_name}
<dt>{t('events.tracker')}</dt>
<dd>{event.tracker_name}</dd>
<dd>{displayEvent.tracker_name}</dd>
{/if}
{#if event.action_name}
{#if displayEvent.action_name}
<dt>{t('events.action')}</dt>
<dd>{event.action_name}</dd>
<dd>{displayEvent.action_name}</dd>
{/if}
{#if event.provider_name}
{#if displayEvent.provider_name}
<dt>{t('events.provider')}</dt>
<dd>{event.provider_name}</dd>
<dd>{displayEvent.provider_name}</dd>
{/if}
{#if event.assets_count > 0}
{#if displayEvent.assets_count > 0}
<dt>{t('events.assetsCount')}</dt>
<dd class="font-mono">{event.assets_count}</dd>
<dd class="font-mono">{displayEvent.assets_count}</dd>
{/if}
</dl>
<!-- Action buttons — deep-link + highlight the related entity card -->
<div class="actions">
{#if event.provider_id}
<button type="button" onclick={() => openEntity('/providers', event.provider_id)}>
{#if displayEvent.provider_id}
<button type="button" onclick={() => openEntity('/providers', displayEvent.provider_id)}>
<MdiIcon name="mdiServer" size={14} />
{t('events.openProvider')}
</button>
{/if}
{#if event.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', event.telegram_bot_id)}>
{#if displayEvent.telegram_bot_id && isCommand}
<button type="button" onclick={() => openEntity('/bots', displayEvent.telegram_bot_id)}>
<MdiIcon name="mdiRobotHappy" size={14} />
{t('events.openBot')}
</button>
{/if}
{#if event.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', event.command_tracker_id)}>
{#if displayEvent.command_tracker_id && isCommand}
<button type="button" onclick={() => openEntity('/command-trackers', displayEvent.command_tracker_id)}>
<MdiIcon name="mdiChat" size={14} />
{t('events.openCommandTracker')}
</button>
{/if}
{#if event.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', event.action_id)}>
{#if displayEvent.action_id && isAction}
<button type="button" onclick={() => openEntity('/actions', displayEvent.action_id)}>
<MdiIcon name="mdiPlayCircle" size={14} />
{t('events.openAction')}
</button>
{/if}
{#if !isCommand && !isAction && event.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', event.tracker_id)}>
{#if !isCommand && !isAction && displayEvent.tracker_id}
<button type="button" onclick={() => openEntity('/notification-trackers', displayEvent.tracker_id)}>
<MdiIcon name="mdiRadar" size={14} />
{t('events.openTracker')}
</button>
@@ -251,4 +379,71 @@
word-break: break-word;
}
.font-mono { font-family: var(--font-mono); }
/* Dispatch lifecycle banner — appears only when the event took the
* quiet-hours defer path. The three colour variants mirror the dashboard
* badge palette: primary glow for "held", success for "delivered late",
* muted/dim for "dropped" / "failed" / "suppressed".
*/
.lifecycle {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.75rem 0.95rem;
border-radius: 0.7rem;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
font-size: 0.82rem;
}
.lifecycle-body {
display: flex; flex-direction: column; gap: 0.2rem;
flex: 1; min-width: 0;
}
.lifecycle-title {
font-weight: 600;
color: var(--color-foreground);
}
.lifecycle-detail {
color: var(--color-foreground);
}
.lifecycle-detail b {
font-family: var(--font-mono);
font-weight: 600;
}
.lifecycle-rel {
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-left: 0.25rem;
}
.lifecycle-hint {
color: var(--color-muted-foreground);
font-size: 0.72rem;
}
.lifecycle-reason {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.05rem 0.35rem;
border-radius: 0.3rem;
background: color-mix(in oklab, var(--color-foreground) 8%, transparent);
word-break: break-all;
}
.lifecycle--deferred {
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.lifecycle--deferred :global(svg) {
color: var(--color-primary);
}
.lifecycle--late {
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent);
}
.lifecycle--late :global(svg) {
color: var(--color-success, #16a34a);
}
.lifecycle--dropped {
opacity: 0.92;
}
.lifecycle--dropped :global(svg) {
color: var(--color-muted-foreground);
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
export type MetaTone = 'default' | 'mint' | 'sky' | 'coral' | 'citrus' | 'orchid' | 'lavender';
export interface MetaTile {
icon?: string;
label: string;
value?: string;
hint?: string;
tone?: MetaTone;
mono?: boolean;
href?: string;
onclick?: (e: MouseEvent) => void;
copyValue?: string;
}
let { tiles, align = 'start' }: {
tiles: MetaTile[];
align?: 'start' | 'end';
} = $props();
function handleClick(e: MouseEvent, tile: MetaTile) {
if (tile.onclick) {
e.preventDefault();
e.stopPropagation();
tile.onclick(e);
}
}
</script>
<div class="meta-strip" style="justify-content: {align === 'end' ? 'flex-end' : 'flex-start'};">
{#each tiles as tile, i (i)}
{#if tile.href}
<a
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
href={tile.href}
target="_blank"
rel="noopener"
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</a>
{:else if tile.onclick}
<button
type="button"
class="meta-tile meta-tone-{tile.tone || 'default'} meta-tile--interactive"
class:meta-tile--mono={tile.mono}
title={tile.hint}
onclick={(e: MouseEvent) => handleClick(e, tile)}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</button>
{:else}
<div
class="meta-tile meta-tone-{tile.tone || 'default'}"
class:meta-tile--mono={tile.mono}
title={tile.hint}
>
{#if tile.icon}
<span class="meta-tile__icon"><MdiIcon name={tile.icon} size={14} /></span>
{/if}
<span class="meta-tile__text">
{#if tile.value}<span class="meta-tile__value">{tile.value}</span>{/if}
<span class="meta-tile__label">{tile.label}</span>
</span>
</div>
{/if}
{/each}
</div>
<style>
.meta-strip {
display: none;
min-width: 0;
flex: 1 1 auto;
gap: 0.45rem;
align-items: center;
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
padding: 2px 18px;
}
@media (min-width: 1024px) {
.meta-strip {
display: flex;
}
}
.meta-tile {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: var(--color-glass);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid var(--color-border);
font-size: 0.72rem;
line-height: 1.1;
color: var(--color-muted-foreground);
white-space: nowrap;
flex-shrink: 0;
max-width: 22rem;
min-width: 0;
text-decoration: none;
font-family: inherit;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.meta-tile__icon {
display: inline-flex;
align-items: center;
color: currentColor;
opacity: 0.9;
flex-shrink: 0;
}
.meta-tile__text {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
}
.meta-tile__value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-foreground);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.meta-tile__label {
font-size: 0.72rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.meta-tile--mono .meta-tile__label,
.meta-tile--mono .meta-tile__value {
font-family: var(--font-mono);
letter-spacing: -0.02em;
font-size: 0.7rem;
}
.meta-tile--interactive {
cursor: pointer;
}
.meta-tile--interactive:hover {
border-color: var(--color-rule-strong);
background: var(--color-glass-strong);
transform: translateY(-1px);
}
/* Tone variants — applied to the dot/icon and accent border on hover */
.meta-tone-mint { box-shadow: inset 2px 0 0 var(--color-mint); }
.meta-tone-sky { box-shadow: inset 2px 0 0 var(--color-sky); }
.meta-tone-coral { box-shadow: inset 2px 0 0 var(--color-coral); }
.meta-tone-citrus { box-shadow: inset 2px 0 0 var(--color-citrus); }
.meta-tone-orchid { box-shadow: inset 2px 0 0 var(--color-orchid); }
.meta-tone-lavender { box-shadow: inset 2px 0 0 var(--color-primary); }
.meta-tone-mint .meta-tile__icon { color: var(--color-mint); }
.meta-tone-sky .meta-tile__icon { color: var(--color-sky); }
.meta-tone-coral .meta-tile__icon { color: var(--color-coral); }
.meta-tone-citrus .meta-tile__icon { color: var(--color-citrus); }
.meta-tone-orchid .meta-tile__icon { color: var(--color-orchid); }
.meta-tone-lavender .meta-tile__icon { color: var(--color-primary); }
</style>
+15 -2
View File
@@ -11,14 +11,22 @@
}>();
let visible = $state(false);
let mounted = $state(false);
let panelEl = $state<HTMLDivElement | undefined>();
let previouslyFocused: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
const TRANSITION_MS = 250;
$effect(() => {
if (open) {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
previouslyFocused = document.activeElement as HTMLElement | null;
mounted = true;
requestAnimationFrame(() => {
visible = true;
// Focus first focusable element inside the modal
@@ -29,13 +37,18 @@
focusable?.focus();
});
});
} else {
} else if (mounted) {
visible = false;
// Restore focus to the previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
mounted = false;
closeTimer = null;
}, TRANSITION_MS);
}
});
@@ -73,7 +86,7 @@
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
{#if mounted}
<div use:portal class="modal-portal-root">
<div
class="modal-backdrop"
+79 -2
View File
@@ -124,6 +124,15 @@
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"heldUntil": "held until",
"deferredTitle": "Quiet hours suppressed this notification; it will dispatch when the window ends.",
"deliveredLate": "delivered late",
"deliveredLateTitle": "This notification fired after the quiet-hours window ended.",
"deferredThenDropped": "dropped after defer",
"deferredThenDroppedTitle": "Held by quiet hours, then dropped — the target or link was removed before the window ended.",
"deferredThenFailed": "failed after defer",
"suppressedQuietHours": "suppressed (quiet hours)",
"suppressedNondeferrableTitle": "Wall-clock event suppressed by quiet hours. Scheduled/periodic/memory dispatches drop rather than defer.",
"asset": "asset",
"assets": "assets",
"eventActivity": "Event Activity",
@@ -179,7 +188,21 @@
"openCommandTracker": "Open command tracker",
"openAction": "Open action",
"openTracker": "Open tracker",
"rawDetails": "Raw details"
"rawDetails": "Raw details",
"lifecycle": {
"heldTitle": "Held by quiet hours",
"heldUntil": "Will dispatch at",
"heldFor": "Held for",
"heldHint": "Notifications during quiet hours wait until the window ends. Add/remove pairs cancel out automatically.",
"inPrefix": "in",
"deliveredLateTitle": "Delivered after quiet hours",
"originalEvent": "Original event",
"droppedTitle": "Dropped after defer",
"failedTitle": "Failed after defer",
"reason": "Reason",
"suppressedTitle": "Suppressed by quiet hours",
"suppressedHint": "Scheduled, periodic, and memory dispatches are wall-clock — they drop instead of deferring so a 'good morning' message doesn't arrive in the afternoon."
}
},
"providers": {
"title": "Service",
@@ -474,6 +497,7 @@
"countLabel": "users",
"title": "Users",
"description": "Manage user accounts (admin only)",
"you": "you",
"addUser": "Add User",
"cancel": "Cancel",
"username": "Username",
@@ -870,7 +894,58 @@
"changedOne": "1 setting changed",
"changedMany": "{n} settings changed",
"discard": "Discard",
"saveChanges": "Save changes"
"saveChanges": "Save changes",
"release": {
"eyebrow": "Releases",
"headline": "Stay current with upstream",
"provider": "Provider",
"providerHint": "Where to check for new versions. Gitea is the only active backend today; GitHub will follow.",
"comingSoon": "Coming soon",
"disabled": "Disabled",
"repository": "Repository",
"repositoryHint": "Public repository URL and owner/name (e.g. alexei.dolgolyov/notify-bridge).",
"options": "Options",
"includePrereleases": "Include pre-releases",
"prereleasesHint": "When off, release candidates and betas are ignored even if they're newer than your installed version.",
"interval": "Check interval",
"intervalHint": "How often the background job probes upstream. Manual checks are always available.",
"intervalRange": "1168 hrs",
"hoursUnit": "hrs",
"testConnection": "Test connection",
"checkNow": "Check now",
"checkDone": "Release check complete",
"checkFailed": "Release check failed",
"testOk": "Provider reachable",
"testFailed": "Provider unreachable",
"testFound": "Provider returned",
"viewRelease": "View v{v} release",
"statusUpToDate": "You're up to date",
"statusUpdate": "Update available",
"statusDisabled": "Release checks disabled",
"statusError": "Last check failed",
"statusUnknown": "Not checked yet",
"heroAvailable": "available",
"updateAvailableTooltip": "v{v} available — open Settings",
"lastChecked": "Last checked",
"never": "never",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} d ago",
"error": {
"disabled": "Release checks are disabled",
"misconfigured": "Provider not fully configured",
"provider_changed": "Provider changed — awaiting next check",
"no_release_found": "No matching release found upstream",
"network_error": "Upstream unreachable",
"http_error": "Upstream returned an error",
"parse_error": "Upstream response could not be parsed",
"unsafe_url": "URL rejected by safety check",
"not_implemented": "Provider not implemented yet",
"unknown_error": "Unknown error",
"error": "Last check failed"
}
}
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
@@ -1031,6 +1106,8 @@
"noMatches": "No timezones match"
},
"locales": {
"label": "language",
"labelPlural": "languages",
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
+79 -2
View File
@@ -124,6 +124,15 @@
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"heldUntil": "ожидает до",
"deferredTitle": "Тихий режим задержал уведомление; оно будет отправлено после окончания окна.",
"deliveredLate": "доставлено позже",
"deliveredLateTitle": "Уведомление отправлено после окончания тихих часов.",
"deferredThenDropped": "отброшено после задержки",
"deferredThenDroppedTitle": "Задержано тихими часами, затем отброшено — цель или связь были удалены до окончания окна.",
"deferredThenFailed": "ошибка после задержки",
"suppressedQuietHours": "подавлено (тихие часы)",
"suppressedNondeferrableTitle": "Событие по расписанию подавлено тихими часами. Запланированные/периодические/воспоминания отбрасываются, а не откладываются.",
"asset": "файл",
"assets": "файлов",
"eventActivity": "Активность событий",
@@ -179,7 +188,21 @@
"openCommandTracker": "Открыть командный трекер",
"openAction": "Открыть действие",
"openTracker": "Открыть трекер",
"rawDetails": "Сырые данные"
"rawDetails": "Сырые данные",
"lifecycle": {
"heldTitle": "Задержано тихими часами",
"heldUntil": "Будет отправлено в",
"heldFor": "Задержано на",
"heldHint": "Уведомления в тихие часы ждут окончания окна. Пары добавление/удаление отменяются автоматически.",
"inPrefix": "через",
"deliveredLateTitle": "Доставлено после тихих часов",
"originalEvent": "Исходное событие",
"droppedTitle": "Отброшено после задержки",
"failedTitle": "Ошибка после задержки",
"reason": "Причина",
"suppressedTitle": "Подавлено тихими часами",
"suppressedHint": "Запланированные, периодические и воспоминания привязаны ко времени — они отбрасываются, а не откладываются, чтобы «доброе утро» не пришло днём."
}
},
"providers": {
"title": "Сервисные",
@@ -474,6 +497,7 @@
"countLabel": "пользователей",
"title": "Пользователи",
"description": "Управление аккаунтами (только админ)",
"you": "вы",
"addUser": "Добавить пользователя",
"cancel": "Отмена",
"username": "Имя пользователя",
@@ -870,7 +894,58 @@
"changedOne": "Изменена 1 настройка",
"changedMany": "Изменено настроек: {n}",
"discard": "Отменить",
"saveChanges": "Сохранить"
"saveChanges": "Сохранить",
"release": {
"eyebrow": "Релизы",
"headline": "Следите за обновлениями",
"provider": "Источник",
"providerHint": "Где искать новые версии. Сейчас доступен только Gitea; GitHub появится позже.",
"comingSoon": "Скоро",
"disabled": "Отключено",
"repository": "Репозиторий",
"repositoryHint": "URL публичного репозитория и owner/name (например, alexei.dolgolyov/notify-bridge).",
"options": "Опции",
"includePrereleases": "Учитывать пре-релизы",
"prereleasesHint": "Если выключено, кандидаты в релизы и бета-версии игнорируются, даже если они новее установленной.",
"interval": "Интервал проверки",
"intervalHint": "Как часто фоновая задача опрашивает источник. Ручная проверка всегда доступна.",
"intervalRange": "1168 ч",
"hoursUnit": "ч",
"testConnection": "Проверить связь",
"checkNow": "Проверить сейчас",
"checkDone": "Проверка релизов завершена",
"checkFailed": "Не удалось проверить релизы",
"testOk": "Источник доступен",
"testFailed": "Источник недоступен",
"testFound": "Найдена версия",
"viewRelease": "Открыть релиз v{v}",
"statusUpToDate": "Актуальная версия",
"statusUpdate": "Доступно обновление",
"statusDisabled": "Проверка релизов отключена",
"statusError": "Ошибка последней проверки",
"statusUnknown": "Ещё не проверялось",
"heroAvailable": "доступна",
"updateAvailableTooltip": "Доступна версия v{v} — открыть Настройки",
"lastChecked": "Последняя проверка",
"never": "никогда",
"justNow": "только что",
"minutesAgo": "{n} мин назад",
"hoursAgo": "{n} ч назад",
"daysAgo": "{n} д назад",
"error": {
"disabled": "Проверка релизов отключена",
"misconfigured": "Источник настроен не полностью",
"provider_changed": "Источник изменён — ожидание следующей проверки",
"no_release_found": "Подходящий релиз на источнике не найден",
"network_error": "Источник недоступен",
"http_error": "Источник вернул ошибку",
"parse_error": "Не удалось разобрать ответ источника",
"unsafe_url": "URL отклонён проверкой безопасности",
"not_implemented": "Источник пока не реализован",
"unknown_error": "Неизвестная ошибка",
"error": "Ошибка последней проверки"
}
}
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
@@ -1031,6 +1106,8 @@
"noMatches": "Нет совпадений"
},
"locales": {
"label": "язык",
"labelPlural": "языков",
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
+47
View File
@@ -20,6 +20,7 @@ import type {
CommandTemplateConfig,
CommandTracker,
Action,
ReleaseStatus,
} from '$lib/types';
/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */
@@ -140,6 +141,46 @@ export const externalUrlCache = (() => {
};
})();
/** Upstream release status — drives the sidebar badge and Settings cassette. */
export const releaseStatusCache = (() => {
let data = $state<ReleaseStatus | null>(null);
let fetchedAt = $state(0);
let inflight: Promise<ReleaseStatus | null> | null = null;
// 5 min TTL — fresh enough that "Check now" feels instant on revisit,
// long enough that route changes don't hammer the endpoint.
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
clear() {
data = null;
fetchedAt = 0;
inflight = null;
},
set(next: ReleaseStatus | null) {
data = next;
fetchedAt = Date.now();
},
async fetch(force = false): Promise<ReleaseStatus | null> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
data = await api<ReleaseStatus>('/settings/release');
fetchedAt = Date.now();
return data;
} catch {
// Swallow — the badge falls back to its default "no status" state.
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
@@ -192,7 +233,13 @@ export async function fetchAllCaches(): Promise<void> {
/**
* Invalidate all entity caches. Useful on logout.
*
* Singleton state caches (release status, external URL, supported locales)
* live outside `allCaches` because their shape differs from entity caches —
* we clear them explicitly so a returning user as a different role can't
* briefly see the previous user's cached payload.
*/
export function clearAllCaches(): void {
Object.values(allCaches).forEach(c => c.clear());
releaseStatusCache.clear();
}
+59 -1
View File
@@ -212,6 +212,29 @@ export interface TemplateConfig {
created_at: string;
}
/**
* Lifecycle marker the backend stores in ``EventLog.details.dispatch_status``
* when a notification doesn't take the immediate-deliver happy path.
*
* * ``deferred`` — held back by quiet hours; ``deferred_until`` carries the
* UTC ISO datetime at which a drain job will fire.
* * ``delivered_after_quiet_hours`` — a drain successfully dispatched the
* originally-deferred event. ``original_event_log_id`` points back at the
* row from when the event was first detected.
* * ``deferred_then_dropped`` — drain time arrived but the link/target was
* removed, disabled, or otherwise unsendable. See ``reason`` for detail.
* * ``deferred_then_failed`` — drain dispatched but the target returned an
* error; ``reason`` carries the truncated provider error.
* * ``suppressed_quiet_hours_nondeferrable`` — wall-clock event type (e.g.
* ``scheduled_message``) caught by quiet hours, dropped on principle.
*/
export type DispatchStatus =
| 'deferred'
| 'delivered_after_quiet_hours'
| 'deferred_then_dropped'
| 'deferred_then_failed'
| 'suppressed_quiet_hours_nondeferrable';
export interface EventLog {
id: number;
event_type: string;
@@ -228,7 +251,12 @@ export interface EventLog {
telegram_bot_id?: number | null;
bot_name?: string;
assets_count: number;
details: Record<string, any>;
details: Record<string, any> & {
dispatch_status?: DispatchStatus;
deferred_until?: string;
original_event_log_id?: number | null;
deferred_for_seconds?: number;
};
created_at: string;
}
@@ -345,3 +373,33 @@ export interface DashboardStatus {
recent_events: EventLog[];
command_trackers?: 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;
}
+61 -2
View File
@@ -18,7 +18,7 @@
providersCache, notificationTrackersCache, trackingConfigsCache,
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
matrixBotsCache, targetsCache,
matrixBotsCache, targetsCache, releaseStatusCache,
} from '$lib/stores/caches.svelte';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
@@ -31,6 +31,17 @@
let allProviders = $derived(providersCache.items);
// Sidebar release indicator — reads from the cache populated in onMount.
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
// A screen reader hits the brand-version link on every page — keep the
// label informative only when an update is available, otherwise announce
// the version + product so we don't repeat "Up to date" everywhere.
const releaseTooltip = $derived(
releaseUpdateAvailable
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
: `Notify Bridge v${__APP_VERSION__}`
);
let providerFilterItems = $derived([
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
@@ -306,6 +317,7 @@
emailBotsCache.fetch(),
matrixBotsCache.fetch(),
targetsCache.fetch(),
releaseStatusCache.fetch(),
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
}
});
@@ -401,7 +413,20 @@
{/if}
Notify Bridge
</h1>
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
<p class="brand-version font-mono">
<a
class="brand-version-link"
class:has-update={releaseUpdateAvailable}
href="/settings#release"
aria-label={releaseTooltip}
title={releaseUpdateAvailable ? releaseTooltip : undefined}
>
<span>v{__APP_VERSION__}</span>
{#if releaseUpdateAvailable}
<span class="brand-version-dot" aria-hidden="true"></span>
{/if}
</a>
</p>
</div>
</div>
{:else}
@@ -772,6 +797,40 @@
letter-spacing: 0.02em;
font-weight: 500;
}
.brand-version-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
text-decoration: none;
border-radius: 0.3rem;
padding: 1px 4px;
margin: -1px -4px;
transition: color 0.15s, background 0.15s;
}
.brand-version-link:hover {
color: var(--color-foreground);
background: var(--color-glass-strong);
}
.brand-version-link.has-update {
color: var(--color-citrus, #d4a73a);
}
.brand-version-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-citrus, #d4a73a);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
animation: brand-version-pulse 2.4s ease-in-out infinite;
}
@keyframes brand-version-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.brand-version-dot { animation: none; }
.brand-version-link { transition: none; }
}
.brand-orb {
width: 32px; height: 32px;
border-radius: 11px;
+61
View File
@@ -724,6 +724,37 @@
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#if event.details?.dispatch_status === 'deferred' && event.details?.deferred_until}
<span class="dispatch-badge dispatch-badge--deferred"
title={t('dashboard.deferredTitle')}>
<MdiIcon name="mdiPauseCircleOutline" size={12} />
{t('dashboard.heldUntil')} {timeShort(event.details.deferred_until)}
</span>
{:else if event.details?.dispatch_status === 'delivered_after_quiet_hours'}
<span class="dispatch-badge dispatch-badge--late"
title={t('dashboard.deliveredLateTitle')}>
<MdiIcon name="mdiClockCheckOutline" size={12} />
{t('dashboard.deliveredLate')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_dropped'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.deferredThenDroppedTitle')}>
<MdiIcon name="mdiCloseCircleOutline" size={12} />
{t('dashboard.deferredThenDropped')}
</span>
{:else if event.details?.dispatch_status === 'deferred_then_failed'}
<span class="dispatch-badge dispatch-badge--dropped"
title={event.details?.reason ?? ''}>
<MdiIcon name="mdiAlertCircleOutline" size={12} />
{t('dashboard.deferredThenFailed')}
</span>
{:else if event.details?.dispatch_status === 'suppressed_quiet_hours_nondeferrable'}
<span class="dispatch-badge dispatch-badge--dropped"
title={t('dashboard.suppressedNondeferrableTitle')}>
<MdiIcon name="mdiVolumeOff" size={12} />
{t('dashboard.suppressedQuietHours')}
</span>
{/if}
{#if event.event_type?.startsWith('command_')}
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
{@const issuerLabel = issuer
@@ -1334,6 +1365,36 @@
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
/* Dispatch lifecycle badges (quiet-hours deferral, late delivery, drops).
* Coloured to match the verb (held = primary glow, late = success, drop
* = muted). The icon is intentionally small so the badge doesn't pull
* focus from the event verb itself. */
.dispatch-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-muted-foreground);
margin-left: 0.4rem;
white-space: nowrap;
}
.dispatch-badge--deferred {
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-glass-strong));
}
.dispatch-badge--late {
color: var(--color-success, #16a34a);
border-color: color-mix(in srgb, var(--color-success, #16a34a) 35%, transparent);
background: color-mix(in srgb, var(--color-success, #16a34a) 10%, var(--color-glass-strong));
}
.dispatch-badge--dropped {
color: var(--color-muted-foreground);
opacity: 0.85;
}
.signal-when {
text-align: right;
font-size: 0.7rem;
+70 -21
View File
@@ -22,6 +22,7 @@
import ExecutionHistory from './ExecutionHistory.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { Action, ActionRule } from '$lib/types';
let allActions = $derived(actionsCache.items);
@@ -193,6 +194,51 @@
if (status === 'failed') return 'var(--color-error-fg)';
return 'var(--color-muted-foreground)';
}
function statusTone(status: string | undefined): MetaTile['tone'] {
if (status === 'success') return 'mint';
if (status === 'partial') return 'citrus';
if (status === 'failed') return 'coral';
return 'default';
}
function actionTiles(action: Action): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(action.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('commandTracker.disabled'), tone: 'default' });
tiles.push({
icon: 'mdiServer',
label: getProviderName(action.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiTagOutline',
label: action.action_type,
tone: 'sky',
mono: true,
});
tiles.push({
icon: action.schedule_type === 'cron' ? 'mdiClockOutline' : 'mdiTimerOutline',
label: formatSchedule(action),
tone: 'orchid',
mono: true,
});
tiles.push({
icon: 'mdiFormatListBulleted',
value: String(action.rules?.length || 0),
label: t('actions.rules'),
tone: (action.rules?.length || 0) > 0 ? 'sky' : 'default',
});
if (action.last_run_status) {
tiles.push({
icon: 'mdiHistory',
label: action.last_run_status,
tone: statusTone(action.last_run_status),
});
}
return tiles;
}
</script>
<PageHeader
@@ -323,32 +369,35 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each actions as action}
<Card hover entityId={action.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background: {action.enabled ? '#059669' : 'var(--color-muted-foreground)'}"></div>
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={action.icon || 'mdiPlayCircleOutline'} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{action.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)] list-row__secondary">
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
<span style="color: {statusColor(action.last_run_status)}">
{action.last_run_status}
</span>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={actionTiles(action)} />
<div class="list-row__actions">
<IconButton icon="mdiPlay" title={t('actions.execute')}
onclick={() => executeAction(action.id)}
disabled={executing[action.id]} />
+34 -8
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -39,6 +40,30 @@
}
});
function emailBotTiles(bot: EmailBot): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiEmailOutline',
label: bot.email,
tone: 'lavender',
mono: true,
});
tiles.push({
icon: 'mdiServerNetwork',
label: `${bot.smtp_host}:${bot.smtp_port}`,
tone: 'sky',
mono: true,
});
if (bot.smtp_use_tls) {
tiles.push({
icon: 'mdiLockOutline',
label: 'TLS',
tone: 'mint',
});
}
return tiles;
}
function openNewEmail() { emailForm = defaultEmailForm(); nameManuallyEdited = false; editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
@@ -165,16 +190,16 @@
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each emailBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
@@ -182,7 +207,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={emailBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
+32 -8
View File
@@ -13,6 +13,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { MatrixBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
@@ -38,6 +39,28 @@
}
});
function matrixBotTiles(bot: MatrixBot): MetaTile[] {
const tiles: MetaTile[] = [];
let host = bot.homeserver_url;
try { host = new URL(bot.homeserver_url).host; } catch { /* keep raw */ }
tiles.push({
icon: 'mdiServerNetwork',
label: host,
hint: bot.homeserver_url,
href: bot.homeserver_url,
tone: 'lavender',
mono: true,
});
if (bot.display_name) {
tiles.push({
icon: 'mdiAccountCircleOutline',
label: bot.display_name,
tone: 'sky',
});
}
return tiles;
}
function openNewMatrix() { matrixForm = defaultMatrixForm(); nameManuallyEdited = false; editingMatrix = null; showMatrixForm = true; }
function editMatrixBot(bot: MatrixBot) {
matrixForm = {
@@ -148,23 +171,24 @@
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each matrixBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<div class="flex items-center gap-2 mt-1 flex-wrap list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
{#if bot.display_name}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={matrixBotTiles(bot)} />
<div class="list-row__actions">
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
+43 -10
View File
@@ -16,6 +16,7 @@
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import Button from '$lib/components/Button.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
interface CommandTrackerSummary { id: number; name: string; icon?: string; enabled: boolean }
@@ -60,6 +61,36 @@
let botListenerStatus = $state<Record<number, CommandTrackerSummary[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function telegramBotTiles(bot: TelegramBot): MetaTile[] {
const tiles: MetaTile[] = [];
const mode = bot.update_mode || 'none';
const modeTone: MetaTile['tone'] = mode === 'webhook' ? 'lavender' : mode === 'polling' ? 'mint' : 'default';
const modeLabel = mode === 'webhook' ? t('telegramBot.webhook') : mode === 'polling' ? t('telegramBot.polling') : t('telegramBot.none');
tiles.push({
icon: mode === 'webhook' ? 'mdiWebhook' : mode === 'polling' ? 'mdiSync' : 'mdiPowerOff',
label: modeLabel,
tone: modeTone,
});
if (bot.bot_username) {
tiles.push({
icon: 'mdiAt',
label: bot.bot_username,
tone: 'sky',
mono: true,
});
}
const chatCount = chats[bot.id]?.length;
if (chatCount !== undefined) {
tiles.push({
icon: 'mdiChat',
value: String(chatCount),
label: t('telegramBot.chats'),
tone: chatCount > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
function openNew() { form = { name: '', icon: '', token: '' }; nameManuallyEdited = false; editing = null; showForm = true; }
function editBot(bot: TelegramBot) { form = { name: bot.name, icon: bot.icon || '', token: '' }; nameManuallyEdited = true; editing = bot.id; showForm = true; }
@@ -343,18 +374,19 @@
<EmptyState icon="mdiRobot" message={t('telegramBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium truncate">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
<span class="text-xs text-[var(--color-muted-foreground)] shrink-0">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
</div>
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: (bot.update_mode || 'none') === 'polling'
@@ -362,10 +394,11 @@
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<MetaStrip tiles={telegramBotTiles(bot)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
disabled={chatsLoading[bot.id]}
@@ -22,6 +22,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -108,6 +109,42 @@
finally { loaded = true; highlightFromUrl(); }
}
function commandConfigTiles(cfg: CommandConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: cfg.provider_type,
tone: 'lavender',
mono: true,
});
const cmdCount = (cfg.enabled_commands || []).length;
tiles.push({
icon: 'mdiSlashForward',
value: String(cmdCount),
label: t('commandConfig.commands'),
tone: cmdCount > 0 ? 'mint' : 'coral',
});
tiles.push({
icon: cfg.response_mode === 'media' ? 'mdiImageOutline' : 'mdiTextBoxOutline',
label: cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText'),
tone: 'sky',
});
tiles.push({
icon: 'mdiNumeric',
value: String(cfg.default_count),
label: t('commandConfig.defaultCount'),
tone: 'citrus',
});
if (cfg.command_template_config_id) {
tiles.push({
icon: 'mdiCodeBracesBox',
label: templateName(cfg.command_template_config_id),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
// Auto-select first provider type with commands
@@ -316,22 +353,20 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as cfg}
<Card hover entityId={cfg.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-success-bg)] text-[var(--color-success-fg)] font-mono">
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{cfg.provider_type}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<div class="flex items-center gap-2 mt-0.5 list-row__secondary">
<span class="text-xs text-[var(--color-muted-foreground)]">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
&middot; {t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</span>
{#if cfg.command_template_config_id}
@@ -339,7 +374,8 @@
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandConfigTiles(cfg)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
</div>
@@ -27,6 +27,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
interface CmdTemplateConfig {
id: number;
@@ -262,6 +263,44 @@
}
}
function cmdTemplateConfigTiles(config: CmdTemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
@@ -587,25 +626,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={cmdTemplateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -21,6 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
@@ -272,6 +273,32 @@
function configName(id: number): string {
return commandConfigs.find(c => c.id === id)?.name || '?';
}
function commandTrackerTiles(trk: any): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push(trk.enabled
? { icon: 'mdiCheckCircle', label: t('commandTracker.enabled'), tone: 'mint' }
: { icon: 'mdiCloseCircle', label: t('commandTracker.disabled'), tone: 'coral' });
tiles.push({
icon: 'mdiServer',
label: providerName(trk.provider_id),
tone: 'lavender',
});
tiles.push({
icon: 'mdiCog',
label: configName(trk.command_config_id),
tone: 'sky',
});
if (trk.listener_count !== undefined) {
tiles.push({
icon: 'mdiAccountMultipleOutline',
value: String(trk.listener_count),
label: t('commandTracker.listeners').toLowerCase(),
tone: trk.listener_count > 0 ? 'orchid' : 'default',
});
}
return tiles;
}
</script>
<PageHeader
@@ -341,29 +368,32 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each trackers as trk}
<Card hover entityId={trk.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium truncate">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded font-mono shrink-0 {trk.enabled
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
<div class="list-row__secondary mt-0.5 flex items-center gap-2 flex-wrap">
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} entityId={trk.provider_id} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} entityId={trk.command_config_id} />
{#if trk.listener_count !== undefined}
<span class="text-xs text-[var(--color-muted-foreground)]">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={commandTrackerTiles(trk)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<IconButton icon={trk.enabled ? 'mdiPause' : 'mdiPlay'} title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]} />
<button onclick={() => toggleListeners(trk.id)}
@@ -22,6 +22,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TrackerForm from './TrackerForm.svelte';
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
import SharedLinkModal from './SharedLinkModal.svelte';
@@ -374,6 +375,54 @@
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
}
/**
* Meta tiles for a tracker row. Visible on lg+ in the dead middle space
* between identity and actions. Mirrors the secondary text shown on narrow
* screens, but as live tiles users can scan at a glance.
*/
function trackerTiles(tracker: Tracker): MetaTile[] {
const tiles: MetaTile[] = [];
const trkDesc = getDescriptor(getProviderType(tracker));
// Status — armed/paused with color tone
tiles.push(tracker.enabled
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
// Provider
tiles.push({
icon: 'mdiServer',
label: getProviderName(tracker.provider_id),
tone: 'lavender',
});
// Collections — count + label (varies per provider descriptor)
const collCount = (tracker.collection_ids || []).length;
if (collCount > 0 || !trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiFolderMultipleOutline',
value: String(collCount),
label: getCollectionLabel(tracker),
tone: 'sky',
});
}
// Scan interval — only meaningful for polling trackers
if (!trkDesc?.webhookBased) {
tiles.push({
icon: 'mdiTimerOutline',
value: `${tracker.scan_interval}s`,
label: t('notificationTracker.every').trim(),
tone: 'orchid',
});
}
// Linked targets
const tgtCount = (tracker.tracker_targets || []).length;
tiles.push({
icon: 'mdiTarget',
value: String(tgtCount),
label: t('notificationTracker.linkedTargets'),
tone: tgtCount > 0 ? 'mint' : 'coral',
});
return tiles;
}
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
@@ -528,27 +577,30 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each notificationTrackers as tracker (tracker.id)}
{@const trkDesc = getDescriptor(getProviderType(tracker))}
<Card hover entityId={tracker.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium truncate">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
<div class="list-row__secondary mt-0.5">
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
</div>
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
<MetaStrip tiles={trackerTiles(tracker)} />
<div class="list-row__actions flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
+99 -29
View File
@@ -25,6 +25,7 @@
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import WebhookPayloadHistory from './WebhookPayloadHistory.svelte';
import type { ServiceProvider } from '$lib/types';
@@ -52,6 +53,67 @@
return externalUrl ? `${externalUrl}${path}` : path;
}
/**
* Build meta tiles for a provider row. Filled into the dead middle space
* on wide displays; on narrow screens the secondary text line takes over.
*/
function providerTiles(provider: ServiceProvider): MetaTile[] {
const tiles: MetaTile[] = [];
const h = health[provider.id];
const provDesc = getDescriptor(provider.type);
// Status — first tile, color-coded
if (h === true) {
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
} else if (h === false) {
tiles.push({ icon: 'mdiCloseCircle', label: t('providers.offline'), tone: 'coral' });
} else {
tiles.push({ icon: 'mdiTimerSand', label: t('providers.checking'), tone: 'citrus' });
}
// Type / connection address
const cfg = provider.config as Record<string, any> | undefined;
if (cfg?.url) {
tiles.push({
icon: 'mdiLinkVariant',
label: shortenUrl(cfg.url),
hint: cfg.url,
href: cfg.url,
tone: 'sky',
mono: true,
});
} else if (cfg?.host) {
tiles.push({
icon: 'mdiServer',
label: `${cfg.host}:${cfg.port || 3493}`,
tone: 'sky',
mono: true,
});
}
// Webhook URL (copy to clipboard)
if (provDesc?.webhookUrlPattern) {
const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token);
tiles.push({
icon: 'mdiContentCopy',
label: t('providers.webhookUrl'),
hint: webhookUrl,
tone: 'orchid',
onclick: (e) => copyWebhookUrl(e, webhookUrl),
});
}
return tiles;
}
/** Trim the visible URL so it fits a meta tile; keep host + first path segment. */
function shortenUrl(url: string): string {
try {
const u = new URL(url);
const segments = u.pathname.split('/').filter(Boolean);
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
return `${u.host}${tail}`;
} catch {
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
}
}
function copyWebhookUrl(e: Event, url: string) {
e.preventDefault();
e.stopPropagation();
@@ -222,7 +284,7 @@
{/if}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<div in:slide={{ duration: 200 }} class="list-stack">
<Card class="mb-6">
<ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3">
@@ -292,9 +354,11 @@
{/if}
{#if !showForm && allProviders.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="list-stack mb-3">
<div class="flex items-center gap-2">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{/if}
@@ -307,37 +371,43 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each providers as provider}
{@const provDesc = getDescriptor(provider.type)}
<Card hover entityId={provider.id}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{provider.type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div class="min-w-0">
<div class="flex items-center gap-2 min-w-0">
<p class="font-medium truncate">{provider.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{provider.type}</span>
</div>
<!-- Narrow-screen secondary line (hidden on lg+ where MetaStrip takes over) -->
<div class="list-row__secondary">
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline break-all">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
{#if provider.config?.url}
<a href={provider.config.url} target="_blank" rel="noopener" class="text-xs text-[var(--color-muted-foreground)] font-mono hover:text-[var(--color-primary)] hover:underline">{provider.config.url}</a>
{:else if provider.config?.host}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={providerTiles(provider)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(provider)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(provider)} variant="danger" />
</div>
+28 -1
View File
@@ -6,11 +6,12 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
import { externalUrlCache, releaseStatusCache } from '$lib/stores/caches.svelte';
import SettingsHero from './SettingsHero.svelte';
import IdentityCassette from './IdentityCassette.svelte';
import TelegramCassette from './TelegramCassette.svelte';
import ReleaseCassette from './ReleaseCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import SaveBar from './SaveBar.svelte';
@@ -36,6 +37,11 @@
log_level: string;
log_format: string;
log_levels: string;
release_provider_kind: string;
release_provider_url: string;
release_provider_repo: string;
release_include_prereleases: string;
release_check_interval_hours: string;
}
const EMPTY: Settings = {
@@ -48,6 +54,11 @@
log_level: 'INFO',
log_format: 'text',
log_levels: '',
release_provider_kind: 'gitea',
release_provider_url: 'https://git.dolgolyov-family.by',
release_provider_repo: 'alexei.dolgolyov/notify-bridge',
release_include_prereleases: '0',
release_check_interval_hours: '12',
};
let loaded = $state(false);
@@ -86,6 +97,8 @@
settings = { ...EMPTY, ...fetched };
baseline = { ...settings };
await loadCacheStats();
// Warm the release status so the cassette renders the strip on first paint.
await releaseStatusCache.fetch();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load settings';
error = msg;
@@ -108,6 +121,12 @@
settings = { ...EMPTY, ...next };
baseline = { ...settings };
externalUrlCache.invalidate();
// Release config may have changed → drop the cached status and
// refetch so the sidebar badge + cassette strip reflect the
// freshly-rescheduled probe without waiting for the next route
// change to trigger another read.
releaseStatusCache.invalidate();
void releaseStatusCache.fetch(true);
snackSuccess(t('settings.saved'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
@@ -171,6 +190,14 @@
/>
</div>
<ReleaseCassette
bind:providerKind={settings.release_provider_kind}
bind:providerUrl={settings.release_provider_url}
bind:providerRepo={settings.release_provider_repo}
bind:includePrereleases={settings.release_include_prereleases}
bind:checkIntervalHours={settings.release_check_interval_hours}
/>
<LoggingCassette
bind:logLevel={settings.log_level}
bind:logFormat={settings.log_format}
@@ -2,6 +2,7 @@
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Button from '$lib/components/Button.svelte';
import Hint from '$lib/components/Hint.svelte';
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
@@ -104,6 +105,7 @@
{#if totalBytes > 0}
<span class="ledger-sep">·</span>
<span class="ledger-bytes font-mono">{formatBytes(totalBytes)}</span>
<Hint text={t('settings.cacheStatsHint')} />
{/if}
</div>
</div>
@@ -0,0 +1,698 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import { api } from '$lib/api';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
import type { ReleaseProviderKind, ReleaseStatus, ReleaseTestResult } from '$lib/types';
interface Props {
// All five fields are persisted as strings via the /settings PUT —
// the parent owns the boundary type. Bool flags use "0" / "1".
providerKind: string;
providerUrl: string;
providerRepo: string;
includePrereleases: string;
checkIntervalHours: string;
}
let {
providerKind = $bindable(),
providerUrl = $bindable(),
providerRepo = $bindable(),
includePrereleases = $bindable(),
checkIntervalHours = $bindable(),
}: Props = $props();
let checking = $state(false);
let testing = $state(false);
let testResult = $state<ReleaseTestResult | null>(null);
const status = $derived(releaseStatusCache.value);
const prereleaseChecked = $derived(includePrereleases === '1');
const isDisabled = $derived(providerKind === 'disabled');
// Stale Test-result on input change is misleading — wipe whenever any of
// the probed parameters change so the strip reflects "current" state.
$effect(() => {
// Touch each parameter to register dependency.
void providerKind; void providerUrl; void providerRepo; void prereleaseChecked;
testResult = null;
});
type Tone = 'mint' | 'citrus' | 'coral' | 'sky';
const stateTone: Tone = $derived.by(() => {
if (!status) return 'sky';
if (status.error && status.error !== 'disabled' && status.error !== 'provider_changed') return 'coral';
if (status.update_available) return 'citrus';
if (status.provider === 'disabled') return 'sky';
return 'mint';
});
const stateLabel = $derived.by(() => {
if (!status) return t('settings.release.statusUnknown');
if (status.provider === 'disabled') return t('settings.release.statusDisabled');
if (status.error && status.error !== 'provider_changed') return t('settings.release.statusError');
if (status.update_available) return t('settings.release.statusUpdate');
if (status.latest) return t('settings.release.statusUpToDate');
return t('settings.release.statusUnknown');
});
// Map backend error taxonomy → localized text. Falls back to the raw code
// only when the key is missing (so a new server code surfaces something).
function localizedError(code: string | null): string {
if (!code) return '';
const key = `settings.release.error.${code}`;
const localized = t(key);
// `t` falls back to the key itself when missing — detect by exact match.
return localized === key ? code : localized;
}
function relTime(iso: string | null): string {
if (!iso) return t('settings.release.never');
const then = Date.parse(iso);
if (!Number.isFinite(then)) return t('settings.release.never');
const diff = Date.now() - then;
const min = Math.round(diff / 60_000);
if (min < 1) return t('settings.release.justNow');
if (min < 60) return t('settings.release.minutesAgo').replace('{n}', String(min));
const h = Math.round(min / 60);
if (h < 24) return t('settings.release.hoursAgo').replace('{n}', String(h));
const d = Math.round(h / 24);
return t('settings.release.daysAgo').replace('{n}', String(d));
}
function setProvider(kind: ReleaseProviderKind): void {
providerKind = kind;
}
function onIntervalInput(e: Event): void {
// The native input emits string values; we keep the contract by
// re-coercing to string before assigning to the bindable prop.
const raw = (e.currentTarget as HTMLInputElement).value;
checkIntervalHours = raw === '' ? '' : String(Math.max(1, Math.min(168, Number(raw))));
}
async function checkNow(): Promise<void> {
checking = true;
try {
const next = await api<ReleaseStatus>('/settings/release/check', { method: 'POST' });
releaseStatusCache.set(next);
snackSuccess(t('settings.release.checkDone'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.checkFailed'));
} finally {
checking = false;
}
}
async function testProvider(): Promise<void> {
testing = true;
testResult = null;
try {
testResult = await api<ReleaseTestResult>('/settings/release/test', {
method: 'POST',
body: JSON.stringify({
provider_kind: providerKind,
provider_url: providerUrl,
provider_repo: providerRepo,
include_prereleases: prereleaseChecked,
}),
});
if (testResult.ok) snackSuccess(t('settings.release.testOk'));
else snackError(t('settings.release.testFailed'));
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('settings.release.testFailed'));
} finally {
testing = false;
}
}
</script>
<section class="rel glass" id="release">
<header class="rel-head">
<div class="rel-eyebrow">
<MdiIcon name="mdiUpdate" size={12} />
<span>{t('settings.release.eyebrow')}</span>
</div>
<h3 class="rel-title">{t('settings.release.headline')}</h3>
</header>
<div class="rel-body">
<!-- 01 Provider — native radios for free keyboard a11y. -->
<div class="row">
<div class="row-label">
<span class="row-num">01</span>
<span class="row-name">
{t('settings.release.provider')}
<Hint text={t('settings.release.providerHint')} />
</span>
</div>
<div class="row-control">
<div class="seg" role="radiogroup" aria-label={t('settings.release.provider')}>
<label class="seg-item" class:seg-active={providerKind === 'gitea'}>
<input
type="radio"
name="release-provider"
value="gitea"
checked={providerKind === 'gitea'}
onchange={() => setProvider('gitea')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGit" size={13} /> Gitea</span>
</label>
<label class="seg-item seg-soon" title={t('settings.release.comingSoon')}>
<input
type="radio"
name="release-provider"
value="github"
disabled
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiGithub" size={13} /> GitHub</span>
</label>
<label class="seg-item" class:seg-active={providerKind === 'disabled'}>
<input
type="radio"
name="release-provider"
value="disabled"
checked={providerKind === 'disabled'}
onchange={() => setProvider('disabled')}
class="seg-radio"
/>
<span class="seg-content"><MdiIcon name="mdiPowerSettings" size={13} /> {t('settings.release.disabled')}</span>
</label>
</div>
</div>
</div>
<!-- 02 Repository -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">02</span>
<span class="row-name">
{t('settings.release.repository')}
<Hint text={t('settings.release.repositoryHint')} />
</span>
</div>
<div class="row-control repo-grid">
<input
bind:value={providerUrl}
placeholder="https://git.example.com"
class="text-input"
type="url"
spellcheck="false"
disabled={isDisabled}
/>
<input
bind:value={providerRepo}
placeholder="owner/repo"
class="text-input mono"
spellcheck="false"
disabled={isDisabled}
/>
</div>
</div>
<!-- 03 Options — slider toggle for include-prereleases. -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">03</span>
<span class="row-name">
{t('settings.release.options')}
<Hint text={t('settings.release.prereleasesHint')} />
</span>
</div>
<div class="row-control">
<button
type="button"
class="toggle"
class:toggle-disabled={isDisabled}
onclick={() => { if (!isDisabled) includePrereleases = prereleaseChecked ? '0' : '1'; }}
aria-pressed={prereleaseChecked}
disabled={isDisabled}
>
<span class="toggle-track" class:toggle-on={prereleaseChecked} aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label-text">{t('settings.release.includePrereleases')}</span>
</button>
</div>
</div>
<!-- 04 Check interval -->
<div class="row" class:row-dim={isDisabled}>
<div class="row-label">
<span class="row-num">04</span>
<span class="row-name">
{t('settings.release.interval')}
<Hint text={t('settings.release.intervalHint')} />
</span>
</div>
<div class="row-control interval">
<input
type="number"
min={1}
max={168}
value={checkIntervalHours}
oninput={onIntervalInput}
class="text-input num"
disabled={isDisabled}
/>
<span class="unit">{t('settings.release.hoursUnit')}</span>
<span class="footnote">{t('settings.release.intervalRange')}</span>
</div>
</div>
</div>
<!-- State strip -->
<footer class="strip" data-tone={stateTone}>
<div class="strip-left">
<span class="dot" data-tone={stateTone} aria-hidden="true"></span>
<div class="strip-text">
<div class="strip-state">{stateLabel}</div>
<div class="strip-meta">
<span class="versions">
<span class="v-current">v{status?.current ?? '—'}</span>
{#if status?.latest && status.latest !== status.current}
<span class="arrow" aria-hidden="true"></span>
<span
class="v-latest"
class:v-latest-update={status.update_available}
>v{status.latest}{#if status.latest_prerelease} · pre{/if}</span>
{/if}
</span>
<span class="sep" aria-hidden="true">·</span>
<span class="checked">
{t('settings.release.lastChecked')}: <span class="rel-time">{relTime(status?.checked_at ?? null)}</span>
</span>
</div>
{#if status?.error && status.error !== 'disabled' && status.error !== 'provider_changed'}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {localizedError(status.error)}
</div>
{/if}
{#if testResult && !testResult.ok}
<div class="strip-error">
<MdiIcon name="mdiAlertCircleOutline" size={12} /> {t('settings.release.testFailed')}:
{localizedError(testResult.error)}
</div>
{/if}
{#if testResult && testResult.ok && testResult.info}
<div class="strip-test-ok">
<MdiIcon name="mdiCheckCircleOutline" size={12} /> {t('settings.release.testFound')}:
<span class="mono">v{testResult.info.version}</span>
</div>
{/if}
</div>
</div>
<div class="strip-actions">
{#if status?.update_available && status.latest_url}
<a
class="strip-btn strip-btn-cta"
href={status.latest_url}
target="_blank"
rel="noopener noreferrer"
>
<MdiIcon name="mdiOpenInNew" size={13} />
<span>{t('settings.release.viewRelease').replace('{v}', status.latest ?? '')}</span>
</a>
{/if}
<button
type="button"
class="strip-btn"
onclick={testProvider}
disabled={testing || isDisabled || !providerRepo}
>
<MdiIcon name={testing ? 'mdiLoading' : 'mdiCheckNetworkOutline'} size={13} />
<span>{t('settings.release.testConnection')}</span>
</button>
<button
type="button"
class="strip-btn strip-btn-primary"
onclick={checkNow}
disabled={checking || isDisabled}
>
<MdiIcon name={checking ? 'mdiLoading' : 'mdiRefresh'} size={13} />
<span>{t('settings.release.checkNow')}</span>
</button>
</div>
</footer>
</section>
<style>
.rel {
padding: 1.5rem 1.6rem 0;
display: flex;
flex-direction: column;
gap: 1.2rem;
overflow: hidden;
}
.rel-head { position: relative; z-index: 1; }
.rel-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.rel-title {
margin: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 42ch;
}
.rel-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 11rem 1fr;
gap: 1.4rem;
padding: 1rem 0;
border-top: 1px solid var(--color-border);
}
.row:first-child { border-top: 0; padding-top: 0.4rem; }
.row-dim { opacity: 0.55; }
.row-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.15rem;
}
.row-num {
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.row-name {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-foreground);
letter-spacing: -0.005em;
display: inline-flex;
align-items: center;
}
.row-control { min-width: 0; }
/* Segmented provider control — uses real radios so arrow-key + tab
navigation just work via the browser. */
.seg {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
}
.seg-item {
display: inline-flex;
align-items: center;
border-radius: 0.45rem;
cursor: pointer;
position: relative;
}
.seg-radio {
position: absolute;
opacity: 0;
pointer-events: none;
inset: 0;
}
.seg-radio:focus-visible + .seg-content {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.seg-content {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 0.45rem;
font-size: 0.78rem;
color: var(--color-muted-foreground);
transition: background 0.18s, color 0.18s;
}
.seg-item:hover:not(.seg-soon) .seg-content {
color: var(--color-foreground);
background: var(--color-glass);
}
.seg-active .seg-content {
color: var(--color-foreground);
background: var(--color-input-bg);
box-shadow: 0 0 0 1px var(--color-primary);
}
.seg-soon { opacity: 0.45; cursor: not-allowed; }
/* Text fields */
.repo-grid {
display: grid;
grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
gap: 0.6rem;
max-width: 100%;
}
.text-input {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.6rem;
background: var(--color-input-bg);
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--color-foreground);
transition: border-color 0.18s, box-shadow 0.18s;
}
.text-input.mono { font-family: var(--font-mono); }
.text-input.num { max-width: 6rem; text-align: right; }
.text-input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow);
}
.text-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* Interval */
.interval { display: inline-flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.unit {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-muted-foreground);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footnote {
font-size: 0.68rem;
color: var(--color-muted-foreground);
font-style: italic;
}
/* Slider toggle — mirrors the backup ScheduleCassette pattern. */
.toggle {
display: inline-flex;
align-items: center;
gap: 0.7rem;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: var(--color-foreground);
cursor: pointer;
}
.toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 4px; }
.toggle-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-rule-strong);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--color-muted-foreground);
transition: transform 0.2s, background 0.2s;
}
.toggle-on {
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
}
.toggle-on .toggle-thumb {
background: white;
transform: translateX(18px);
}
.toggle-label-text { font-size: 0.82rem; }
.toggle-disabled { opacity: 0.55; cursor: not-allowed; }
/* State strip */
.strip {
margin: 0 -1.6rem;
padding: 1rem 1.6rem;
border-top: 1px solid var(--color-border);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--color-glass-strong) 60%, transparent),
transparent
);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
position: relative;
}
.strip[data-tone="citrus"]::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 10%,
color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent) 50%,
transparent 90%
);
animation: aurora-shimmer 4s linear infinite;
}
.strip-left { display: flex; align-items: flex-start; gap: 0.7rem; min-width: 0; flex: 1 1 auto; }
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
margin-top: 0.45rem;
flex-shrink: 0;
}
.dot[data-tone="mint"] { background: var(--color-mint, #6fcfa6); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint, #6fcfa6) 60%, transparent); }
.dot[data-tone="citrus"] { background: var(--color-citrus, #d4a73a); box-shadow: 0 0 10px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent); }
.dot[data-tone="coral"] { background: var(--color-coral, #d27a7a); box-shadow: 0 0 8px color-mix(in srgb, var(--color-coral, #d27a7a) 60%, transparent); }
.dot[data-tone="sky"] { background: var(--color-muted-foreground); }
.strip-text { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.strip-state {
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--color-foreground);
}
.strip-meta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.74rem;
color: var(--color-muted-foreground);
}
.versions { display: inline-flex; align-items: center; gap: 0.35rem; }
.v-current { font-family: var(--font-mono); color: var(--color-foreground); }
.arrow { color: var(--color-muted-foreground); }
.v-latest { font-family: var(--font-mono); color: var(--color-foreground); }
.v-latest-update { color: var(--color-citrus, #d4a73a); font-weight: 600; }
.sep { opacity: 0.5; }
.rel-time { color: var(--color-foreground); }
.strip-error {
font-size: 0.72rem;
color: var(--color-coral, #d27a7a);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-test-ok {
font-size: 0.72rem;
color: var(--color-mint, #6fcfa6);
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
}
.strip-actions { display: inline-flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; }
.strip-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-rule-strong);
border-radius: 0.55rem;
background: var(--color-input-bg);
font-size: 0.76rem;
color: var(--color-foreground);
cursor: pointer;
text-decoration: none;
transition: background 0.18s, border-color 0.18s, transform 0.18s;
}
.strip-btn:hover:not(:disabled) {
background: var(--color-glass-strong);
border-color: var(--color-primary);
}
.strip-btn:active:not(:disabled) { transform: translateY(1px); }
.strip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.strip-btn-primary {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-input-bg));
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-rule-strong));
}
/* The CTA — high-visibility when an update is available. */
.strip-btn-cta {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 26%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 14%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 55%, var(--color-rule-strong));
color: var(--color-foreground);
font-weight: 500;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-citrus, #d4a73a) 25%, transparent);
}
.strip-btn-cta:hover {
background: linear-gradient(135deg,
color-mix(in srgb, var(--color-citrus, #d4a73a) 40%, var(--color-input-bg)),
color-mix(in srgb, var(--color-citrus, #d4a73a) 22%, var(--color-input-bg))
);
border-color: color-mix(in srgb, var(--color-citrus, #d4a73a) 75%, var(--color-rule-strong));
}
.mono { font-family: var(--font-mono); }
@keyframes aurora-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.strip[data-tone="citrus"]::before { animation: none; }
.strip-btn { transition: none; }
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
gap: 0.55rem;
padding: 0.95rem 0;
}
.row-label { padding-top: 0; }
.repo-grid { grid-template-columns: 1fr; }
.strip { flex-direction: column; align-items: stretch; }
.strip-actions { justify-content: stretch; }
.strip-btn { flex: 1; justify-content: center; }
}
</style>
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import PageHeader, { type HeaderPill } from '$lib/components/PageHeader.svelte';
import { releaseStatusCache } from '$lib/stores/caches.svelte';
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
@@ -81,6 +82,19 @@
tone: SEVERITY_TONE[lvl] ?? 'mint',
});
const rs = releaseStatusCache.value;
if (rs) {
if (rs.provider === 'disabled') {
out.push({ label: t('settings.release.statusDisabled'), tone: 'sky' });
} else if (rs.error && rs.error !== 'provider_changed') {
out.push({ label: t('settings.release.statusError'), tone: 'coral' });
} else if (rs.update_available && rs.latest) {
out.push({ label: `v${rs.latest} ${t('settings.release.heroAvailable')}`, tone: 'citrus' });
} else if (rs.latest) {
out.push({ label: t('settings.release.statusUpToDate'), tone: 'mint' });
}
}
return out;
});
</script>
+57 -2
View File
@@ -15,6 +15,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -94,6 +95,53 @@
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
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);
@@ -660,7 +708,7 @@
{@const childLabel = target.type === 'broadcast' ? t('targets.childTargets') : t('targets.receivers')}
<Card hover entityId={target.id}>
<!-- Target header (clickable to toggle receiver visibility) -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="target-summary"
@@ -682,6 +730,7 @@
<span class="target-summary__count target-summary__count--empty">{t('targets.noReceivers')}</span>
{/if}
</button>
<MetaStrip tiles={targetTiles(target)} />
<div class="flex items-center gap-1 shrink-0">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
@@ -765,7 +814,7 @@
}
.target-summary {
flex: 1;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
@@ -780,6 +829,12 @@
border-radius: 8px;
transition: background 0.15s ease;
}
@media (min-width: 1024px) {
.target-summary {
flex: 0 1 auto;
max-width: 32rem;
}
}
.target-summary:hover {
background: var(--color-glass-strong);
}
@@ -28,6 +28,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import { getDescriptor } from '$lib/providers';
import type { TemplateConfig } from '$lib/types';
@@ -426,6 +427,45 @@
setTimeout(() => refreshAllPreviews(), 100);
}
function templateConfigTiles(config: TemplateConfig): MetaTile[] {
const tiles: MetaTile[] = [];
tiles.push({
icon: 'mdiServer',
label: config.provider_type,
tone: 'lavender',
mono: true,
});
const slotCount = Object.keys(config.slots || {}).length;
tiles.push({
icon: 'mdiViewGridOutline',
value: String(slotCount),
label: t('templateConfig.slots'),
tone: slotCount > 0 ? 'sky' : 'default',
});
// Locale coverage — count unique locales present across all slots
const locales = new Set<string>();
for (const s of Object.values(config.slots || {})) {
for (const loc of Object.keys(s || {})) locales.add(loc);
}
if (locales.size > 0) {
tiles.push({
icon: 'mdiTranslate',
value: String(locales.size),
label: locales.size === 1 ? t('locales.label') : t('locales.labelPlural'),
hint: [...locales].sort().join(', '),
tone: 'mint',
});
}
if (config.user_id === 0) {
tiles.push({
icon: 'mdiShieldStarOutline',
label: t('common.system'),
tone: 'orchid',
});
}
return tiles;
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
@@ -627,24 +667,25 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] shrink-0">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mt-1 list-row__secondary">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<MetaStrip tiles={templateConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
@@ -26,6 +26,7 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -238,6 +239,38 @@
window.history.replaceState(null, '', cleanUrl);
}
function trackingConfigTiles(config: Record<string, any>): MetaTile[] {
const tiles: MetaTile[] = [];
const desc = getDescriptor(config.provider_type);
const events = (desc?.eventFields ?? []).filter(f => config[f.key]);
tiles.push({
icon: 'mdiPulse',
value: String(events.length),
label: t('trackingConfig.eventTracking'),
hint: events.map(f => t(f.label)).join(', ') || undefined,
tone: events.length > 0 ? 'lavender' : 'default',
});
if (config.periodic_enabled) {
tiles.push({ icon: 'mdiTimerSyncOutline', label: t('trackingConfig.periodic'), tone: 'mint' });
}
if (config.scheduled_enabled) {
tiles.push({ icon: 'mdiCalendarClock', label: t('trackingConfig.scheduled'), tone: 'sky' });
}
if (config.memory_enabled) {
tiles.push({ icon: 'mdiHistory', label: t('trackingConfig.memory'), tone: 'orchid' });
}
if (config.quiet_hours_start && config.quiet_hours_end) {
tiles.push({
icon: 'mdiWeatherNight',
label: `${config.quiet_hours_start}${config.quiet_hours_end}`,
hint: t('trackingConfig.quietHoursStart'),
tone: 'citrus',
mono: true,
});
}
return tiles;
}
function openNew() { form = defaultForm(); nameManuallyEdited = false; editing = null; showForm = true; }
function edit(c: any) {
form = { ...defaultForm(), ...c };
@@ -448,25 +481,26 @@
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each configs as config}
{@const desc = getDescriptor(config.provider_type)}
<Card hover entityId={config.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
<div class="list-row">
<div class="list-row__identity">
<div class="flex items-center gap-2 min-w-0">
<span style="color: var(--color-primary);" class="shrink-0"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium truncate">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono shrink-0">{config.provider_type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={trackingConfigTiles(config)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
+33 -6
View File
@@ -14,6 +14,7 @@
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import type { User } from '$lib/types';
const auth = getAuth();
@@ -87,6 +88,31 @@
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
}
function userTiles(user: User): MetaTile[] {
const tiles: MetaTile[] = [];
const isAdmin = user.role === 'admin';
tiles.push({
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
tone: isAdmin ? 'orchid' : 'sky',
});
tiles.push({
icon: 'mdiCalendarOutline',
label: parseDate(user.created_at).toLocaleDateString(),
hint: t('users.joined'),
tone: 'lavender',
mono: true,
});
if (user.id === auth.user?.id) {
tiles.push({
icon: 'mdiAccountStar',
label: t('users.you', 'you'),
tone: 'mint',
});
}
return tiles;
}
</script>
<PageHeader
@@ -133,15 +159,16 @@
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
<div class="list-stack stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
<div class="list-row">
<div class="list-row__identity">
<p class="font-medium truncate">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<MetaStrip tiles={userTiles(user)} />
<div class="list-row__actions">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.7.2"
version = "0.8.0"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -0,0 +1,32 @@
"""Upstream release-check providers.
This package is intentionally separate from :mod:`notify_bridge_core.providers`:
* service providers are user-configured entities persisted per-tenant in the DB;
* release providers are admin-level upstream-version probes selected by setting,
with at most one active provider per installation.
Mixing them in one enum/factory bled responsibilities and complicated future
additions (e.g. a GitHub release provider that has nothing to do with Gitea
service integrations).
"""
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProvider,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
)
from .registry import build_release_provider
__all__ = [
"ReleaseErrorCode",
"ReleaseInfo",
"ReleaseProvider",
"ReleaseProviderKind",
"ReleaseTestResult",
"build_release_provider",
"is_valid_repo",
]
@@ -0,0 +1,156 @@
"""ReleaseProvider abstraction and shared tag/version utilities."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Protocol, TypedDict, runtime_checkable
class ReleaseProviderKind(str, Enum):
"""Supported upstream release-check providers."""
DISABLED = "disabled"
GITEA = "gitea"
GITHUB = "github"
# Single source of truth for `release_error` taxonomy. Surfaced into the cached
# `AppSetting`, returned via the API, and translated by the frontend.
class ReleaseErrorCode(str, Enum):
DISABLED = "disabled"
MISCONFIGURED = "misconfigured"
PROVIDER_CHANGED = "provider_changed"
NO_RELEASE_FOUND = "no_release_found"
NETWORK_ERROR = "network_error"
HTTP_ERROR = "http_error"
PARSE_ERROR = "parse_error"
UNSAFE_URL = "unsafe_url"
NOT_IMPLEMENTED = "not_implemented"
UNKNOWN_ERROR = "unknown_error"
@dataclass(frozen=True)
class ReleaseInfo:
"""Normalised release metadata returned by a provider."""
tag: str
version: str
name: str | None = None
body: str | None = None
url: str | None = None
published_at: str | None = None
prerelease: bool = False
draft: bool = False
class ReleaseTestResult(TypedDict):
"""Structured shape returned by :meth:`ReleaseProvider.test`."""
ok: bool
info: ReleaseInfo | None
error: str | None
@runtime_checkable
class ReleaseProvider(Protocol):
"""Protocol implemented by every release provider.
Implementations are expected to be safe to instantiate without external
side effects connectivity is deferred until :meth:`fetch_latest` or
:meth:`test` is awaited.
"""
kind: ClassVar[ReleaseProviderKind]
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
"""Return the latest release, or ``None`` if there is nothing to report."""
async def test(self) -> ReleaseTestResult:
"""Probe the upstream and return a structured status payload."""
# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
def is_valid_repo(repo: str) -> bool:
"""``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""
return bool(repo) and _REPO_RE.match(repo) is not None
_TAG_NUMERIC = re.compile(r"\d+")
# Stop reading numeric segments at the first non-digit-non-dot character so
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
_HEAD_SPLIT = re.compile(r"[^0-9.]")
def normalise_version(tag: str) -> str:
"""Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""
if not tag:
return ""
cleaned = tag.strip()
if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
cleaned = cleaned[1:]
return cleaned
def _split_version(version: str) -> tuple[tuple[int, ...], str]:
"""Split a version into (numeric segments, prerelease suffix).
A non-empty prerelease suffix marks the version as pre-stable. We use it
as a tie-break only when numeric segments are equal a stable build
sorts strictly newer than its pre-release counterpart (``0.7.2`` >
``0.7.2-rc1``), which prevents the badge from flickering between
"up to date" and "downgrade available" on installs that ship the GA.
"""
if not version:
return (), ""
work = version.split("+", 1)[0]
if "-" in work:
head, _, suffix = work.partition("-")
else:
# Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
# first non-digit-non-dot is treated as the suffix.
m = _HEAD_SPLIT.search(work)
if m and m.start() > 0:
head, suffix = work[: m.start()], work[m.start():]
else:
head, suffix = work, ""
segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
return segments, suffix.strip()
def compare_versions(a: str, b: str) -> int:
"""Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.
Numeric segments win. When numerically equal, *stable* (no suffix) beats
*prerelease* (any non-empty suffix); two equally-prereleased versions
compare equal we deliberately do not order ``rc2`` over ``rc1`` because
that requires real semver parsing and would only matter for downgrades.
"""
sa, suffix_a = _split_version(normalise_version(a))
sb, suffix_b = _split_version(normalise_version(b))
length = max(len(sa), len(sb))
for i in range(length):
x = sa[i] if i < len(sa) else 0
y = sb[i] if i < len(sb) else 0
if x != y:
return 1 if x > y else -1
# Equal numerics — stable beats prerelease.
if not suffix_a and suffix_b:
return 1
if suffix_a and not suffix_b:
return -1
return 0
def is_newer(candidate: str, baseline: str) -> bool:
"""``True`` when ``candidate`` is strictly newer than ``baseline``."""
return compare_versions(candidate, baseline) > 0
@@ -0,0 +1,167 @@
"""Gitea release provider — queries ``/api/v1/repos/{owner}/{repo}/releases``."""
from __future__ import annotations
import asyncio
import logging
from typing import ClassVar
import aiohttp
from ..notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
normalise_version,
)
_LOGGER = logging.getLogger(__name__)
# Cap upstream response body — release lists are normally a few KB; anything
# beyond this is either a misconfigured target or a malicious payload.
_MAX_BODY_BYTES = 1_000_000
class GiteaReleaseProvider:
"""Anonymous Gitea release probe.
Hits the ``releases`` endpoint (not ``releases/latest``) because the latter
skips pre-releases unconditionally we want to honour the caller's
``include_prereleases`` flag instead of relying on Gitea's filtering.
"""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITEA
def __init__(self, session: aiohttp.ClientSession, url: str, repo: str) -> None:
if not url:
raise ValueError("Gitea release provider requires a base URL")
if not is_valid_repo(repo):
raise ValueError(
"Gitea release provider requires repo as 'owner/name' "
"(alphanumerics, dot, dash, underscore only)"
)
self._session = session
self._url = url.rstrip("/")
self._repo = repo.strip("/")
@property
def _endpoint(self) -> str:
return f"{self._url}/api/v1/repos/{self._repo}/releases"
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError as err:
_LOGGER.warning("Gitea release URL rejected by SSRF guard: %s", err)
return None
try:
async with self._session.get(
self._endpoint,
params={"limit": "20", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
_LOGGER.warning(
"Gitea releases fetch failed: HTTP %s for %s",
response.status, self._endpoint,
)
return None
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response exceeded %d bytes — refusing to parse",
_MAX_BODY_BYTES,
)
return None
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Gitea releases fetch error: %s", err)
return None
except (ValueError, UnicodeDecodeError) as err:
_LOGGER.warning("Gitea releases parse error: %s", err)
return None
if not isinstance(payload, list):
return None
for entry in payload:
if not isinstance(entry, dict):
continue
if entry.get("draft"):
continue
if entry.get("prerelease") and not include_prereleases:
continue
return _to_release_info(entry)
return None
async def test(self) -> ReleaseTestResult:
# Validate URL first so the "test" button surfaces an SSRF rejection
# to the operator rather than silently returning "unreachable".
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
async with self._session.get(
self._endpoint,
params={"limit": "1", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
return {"ok": False, "info": None, "error": ReleaseErrorCode.HTTP_ERROR.value}
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.NETWORK_ERROR.value}
except (ValueError, UnicodeDecodeError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
if not isinstance(payload, list) or not payload:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NO_RELEASE_FOUND.value}
first = payload[0]
if not isinstance(first, dict):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
return {"ok": True, "info": _to_release_info(first), "error": None}
def _to_release_info(entry: dict) -> ReleaseInfo:
tag = str(entry.get("tag_name") or "").strip()
return ReleaseInfo(
tag=tag,
version=normalise_version(tag),
name=entry.get("name") or None,
body=entry.get("body") or None,
url=entry.get("html_url") or None,
published_at=entry.get("published_at") or entry.get("created_at") or None,
prerelease=bool(entry.get("prerelease", False)),
draft=bool(entry.get("draft", False)),
)
@@ -0,0 +1,34 @@
"""GitHub release provider stub.
Reserved so the registry advertises the option and the frontend can render the
provider toggle without a follow-up backend release. The full implementation
will mirror :class:`GiteaReleaseProvider` against
``api.github.com/repos/{owner}/{repo}/releases``.
"""
from __future__ import annotations
from typing import ClassVar
import aiohttp
from .base import ReleaseErrorCode, ReleaseInfo, ReleaseProviderKind, ReleaseTestResult
class GitHubReleaseProvider:
"""Not yet implemented — placeholder so the registry is forward-compatible."""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITHUB
def __init__(self, session: aiohttp.ClientSession, repo: str) -> None:
self._session = session
self._repo = repo
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
# Soft-fail rather than raise — `run_check` already catches
# NotImplementedError but a None return keeps the persisted
# `release_error` taxonomy clean (NOT_IMPLEMENTED, not "not impl…").
return None
async def test(self) -> ReleaseTestResult:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NOT_IMPLEMENTED.value}
@@ -0,0 +1,51 @@
"""Factory for release providers — single entry point for callers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ReleaseProvider, ReleaseProviderKind, is_valid_repo
from .gitea import GiteaReleaseProvider
from .github import GitHubReleaseProvider
if TYPE_CHECKING:
import aiohttp
def build_release_provider(
kind: str | ReleaseProviderKind,
*,
session: aiohttp.ClientSession,
url: str = "",
repo: str = "",
) -> ReleaseProvider | None:
"""Build a release provider for the given kind.
Returns ``None`` when disabled or when required configuration is missing
or unsafe (invalid repo format, empty URL) callers treat the absence as
"no checks performed" without branching on the kind string everywhere.
"""
try:
normalised = (
ReleaseProviderKind(kind)
if not isinstance(kind, ReleaseProviderKind)
else kind
)
except ValueError:
return None
if normalised is ReleaseProviderKind.DISABLED:
return None
if normalised is ReleaseProviderKind.GITEA:
if not url or not is_valid_repo(repo):
return None
try:
return GiteaReleaseProvider(session=session, url=url, repo=repo)
except ValueError:
return None
if normalised is ReleaseProviderKind.GITHUB:
if not is_valid_repo(repo):
return None
return GitHubReleaseProvider(session=session, repo=repo)
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.7.2"
version = "0.8.0"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -2,13 +2,18 @@
import logging
import os
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from notify_bridge_core.release import ReleaseProviderKind, is_valid_repo
from ..auth.dependencies import get_current_user, require_admin
from ..auth.routes import limiter # shared SlowAPI instance (app.state.limiter)
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -28,6 +33,12 @@ _SETTING_KEYS = {
"log_level": "NOTIFY_BRIDGE_LOG_LEVEL", # DEBUG/INFO/WARNING/ERROR
"log_format": "NOTIFY_BRIDGE_LOG_FORMAT", # text|json (requires restart to switch)
"log_levels": "NOTIFY_BRIDGE_LOG_LEVELS", # module=LEVEL,module2=LEVEL
# Release-check — see services/release_check.py for the cached-state keys.
"release_provider_kind": "NOTIFY_BRIDGE_RELEASE_PROVIDER", # disabled|gitea|github
"release_provider_url": "NOTIFY_BRIDGE_RELEASE_PROVIDER_URL",
"release_provider_repo": "NOTIFY_BRIDGE_RELEASE_PROVIDER_REPO",
"release_include_prereleases": None, # "0"|"1"
"release_check_interval_hours": None, # 1..168
}
_DEFAULTS = {
@@ -42,6 +53,13 @@ _DEFAULTS = {
"log_level": "INFO",
"log_format": "text",
"log_levels": "",
# Pre-seed Gitea release checks against this repo's own upstream so a fresh
# install knows where to look without operator intervention.
"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",
}
# Settings whose changes require dropping in-memory Telegram caches so the
@@ -53,6 +71,17 @@ _CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_ent
# changing it means swapping the handler formatter entirely.
_LOG_SETTING_KEYS = {"log_level", "log_levels", "log_format"}
# Release-check settings whose change must trigger cache invalidation (so a
# stale "latest version" doesn't linger after pointing at a new repo) and a
# scheduler re-arm so the new interval/provider takes effect immediately.
_RELEASE_PROVIDER_KEYS = {
"release_provider_kind",
"release_provider_url",
"release_provider_repo",
"release_include_prereleases",
}
_RELEASE_INTERVAL_KEY = "release_check_interval_hours"
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
@@ -81,6 +110,11 @@ class SettingsUpdate(BaseModel):
log_level: str | None = None
log_format: str | None = None
log_levels: str | None = None
release_provider_kind: str | None = None
release_provider_url: str | None = None
release_provider_repo: str | None = None
release_include_prereleases: bool | int | str | None = None
release_check_interval_hours: int | str | None = None
@router.get("")
@@ -111,12 +145,65 @@ async def update_settings(
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
old_timezone = await get_setting(session, "timezone")
old_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
old_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
old_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
for key in _SETTING_KEYS:
value = getattr(body, key, None)
if value is None:
continue
value_str = str(value)
# Normalise per-key before storing so the cache keys always hold the
# canonical wire format ("0"/"1" for bool flags, clamped int for the
# release interval). Without this, str(True) would leak "True" into the
# release_include_prereleases cell and silently disable filtering.
if key == "release_include_prereleases":
if isinstance(value, bool):
value_str = "1" if value else "0"
else:
value_str = "1" if str(value).strip().lower() in ("1", "true", "yes", "on") else "0"
elif key == "release_check_interval_hours":
from ..services.release_check import parse_interval_hours
value_str = str(parse_interval_hours(str(value)))
elif key == "release_provider_kind":
# Reject anything outside the enum so a typo doesn't leave the DB
# in a state the service can't interpret.
value_str = str(value).strip().lower()
try:
value_str = ReleaseProviderKind(value_str).value
except ValueError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_kind: {value_str!r}",
) from err
elif key == "release_provider_url":
value_str = str(value).strip()
if value_str:
# Reject embedded userinfo (http://user:pass@host) so the
# GET /settings response can never echo credentials back, and
# block private/loopback/metadata targets via the SSRF guard.
parsed = urlparse(value_str)
if parsed.username or parsed.password:
raise HTTPException(
status_code=400,
detail="release_provider_url must not contain credentials",
)
try:
await avalidate_outbound_url(value_str)
except UnsafeURLError as err:
raise HTTPException(
status_code=400,
detail=f"Invalid release_provider_url: {err}",
) from err
elif key == "release_provider_repo":
value_str = str(value).strip()
if value_str and not is_valid_repo(value_str):
raise HTTPException(
status_code=400,
detail="release_provider_repo must match 'owner/name' "
"(alphanumerics, dot, dash, underscore only)",
)
else:
value_str = str(value)
# GET masks the webhook secret as "***<last4>" so the real value is
# never exposed to the frontend. If the client sends the mask back
# (which happens on every save, since bind:value holds whatever GET
@@ -182,6 +269,27 @@ async def update_settings(
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
await _reregister_webhooks(session, new_base_url, new_secret)
# Release-check: clear stale cache when the provider repo/url/kind changes,
# and re-arm the periodic job whenever the interval or provider changes.
new_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
new_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
release_provider_changed = new_release_values != old_release_values
release_interval_changed = new_release_interval != old_release_interval
if release_provider_changed:
from datetime import datetime, timezone
from notify_bridge_core.release import ReleaseErrorCode
from ..services.release_check import persist_release_state
await persist_release_state(
checked_at=datetime.now(timezone.utc).isoformat(),
error=ReleaseErrorCode.PROVIDER_CHANGED.value,
info=None,
)
if release_provider_changed or release_interval_changed:
from ..services.scheduler import reschedule_release_check
await reschedule_release_check()
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
@@ -231,6 +339,122 @@ async def get_external_url(
return {"external_url": (await get_setting(session, "external_url")).rstrip("/")}
def _status_payload(status, *, is_admin: bool) -> dict:
"""Serialise a :class:`ReleaseStatus` for the API.
Non-admin payloads strip the upstream release body (an XSS landmine
arbitrary attacker-controlled markdown should never reach a non-admin
UI unless we explicitly sanitise it for display) and replace the raw
error string with a coarse ``error`` / ``ok`` marker so internal
hostnames from probe failures can't leak via the badge.
"""
payload = {
"provider": status.provider,
"current": status.current,
"latest": status.latest,
"latest_tag": status.latest_tag,
"latest_url": status.latest_url,
"latest_name": status.latest_name,
"latest_published_at": status.latest_published_at,
"latest_prerelease": status.latest_prerelease,
"checked_at": status.checked_at,
"update_available": status.update_available,
}
if is_admin:
payload["latest_body"] = status.latest_body
payload["error"] = status.error
else:
payload["latest_body"] = None
payload["error"] = None if not status.error else "error"
return payload
@router.get("/release")
async def get_release_status(
user: User = Depends(get_current_user),
):
"""Return the cached upstream release status (no network call).
Available to all authenticated users so the sidebar badge can render for
everyone admins manage the configuration but the awareness is global.
"""
from ..services.release_check import load_status
return _status_payload(await load_status(), is_admin=(user.role == "admin"))
@router.post("/release/check")
@limiter.limit("6/minute")
async def force_release_check(
request: Request,
user: User = Depends(require_admin),
):
"""Force an immediate upstream check and return the refreshed status."""
from ..services.release_check import run_check
status = await run_check(force=True)
return _status_payload(status, is_admin=True)
class ReleaseTestRequest(BaseModel):
provider_kind: str
provider_url: str | None = None
provider_repo: str | None = None
include_prereleases: bool | None = False
@router.post("/release/test")
@limiter.limit("12/minute")
async def test_release_provider(
request: Request,
body: ReleaseTestRequest,
user: User = Depends(require_admin),
):
"""Dry-run an arbitrary provider config — used by the cassette's Test button.
Validates the provider URL on the spot (SSRF + userinfo) so the operator
sees an actionable error before any outbound request fires.
"""
from notify_bridge_core.release import ReleaseErrorCode, build_release_provider
from ..services.http_session import get_http_session
test_url = (body.provider_url or "").strip()
test_repo = (body.provider_repo or "").strip()
if test_repo and not is_valid_repo(test_repo):
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
if test_url:
parsed = urlparse(test_url)
if parsed.username or parsed.password:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
await avalidate_outbound_url(test_url)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
http = await get_http_session()
provider = build_release_provider(
body.provider_kind,
session=http,
url=test_url,
repo=test_repo,
)
if provider is None:
return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
result = await provider.test()
info = result.get("info")
info_dict = None
if info is not None:
info_dict = {
"tag": info.tag,
"version": info.version,
"name": info.name,
"url": info.url,
"published_at": info.published_at,
"prerelease": info.prerelease,
}
return {"ok": result["ok"], "info": info_dict, "error": result.get("error")}
async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str
) -> None:
@@ -28,8 +28,9 @@ from ..database.models import (
WebhookPayloadLog,
)
from ..services.dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -164,7 +165,16 @@ async def _dispatch_webhook_event(
Number of successfully dispatched notifications.
"""
dispatched = 0
# ``defers_to_schedule`` is collected during the loop and flushed AFTER the
# main session commits — the only side-effect of failing to schedule is a
# delayed delivery (the startup loader / catch-up scan will reschedule),
# so this is best-effort and must not roll back the DB writes.
defers_to_schedule: set[Any] = set()
async with AsyncSession(engine) as session:
# App timezone is identical across trackers within one webhook request;
# pull it once.
app_tz = await get_app_timezone(session)
tracker_result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider_id,
@@ -173,6 +183,8 @@ async def _dispatch_webhook_event(
)
trackers = tracker_result.all()
from ..services.deferred_dispatch import defer_event, is_deferrable
for tracker in trackers:
filters = tracker.filters or {}
if not _passes_filters(event, filters):
@@ -185,11 +197,9 @@ async def _dispatch_webhook_event(
if not link_data:
continue
app_tz = await get_app_timezone(session)
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
event_log_row = EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id,
tracker_name=tracker.name,
@@ -203,18 +213,90 @@ async def _dispatch_webhook_event(
"provider_type": event.provider_type.value,
**extra_details,
},
))
)
session.add(event_log_row)
await session.flush()
event_log_id = event_log_row.id
# Dispatch to targets
# Dedupe defers by parent ``link_id``: broadcast links emit one
# ``link_data`` entry per child, all sharing the same parent id —
# the deferred row is one-per-link, so we only call ``defer_event``
# once per distinct id (earliest fire_at wins on ties).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
defers_for_event: dict[int, Any] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp event_log dispatch_status in the same
# session that holds the EventLog row, so the "deferred" badge
# only appears if the underlying queue rows actually exist.
if defers_for_event:
earliest = min(defers_for_event.values())
for link_id, fire_at in defers_for_event.items():
await defer_event(
session,
event=event,
user_id=tracker.user_id,
tracker_id=tracker.id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
details = dict(event_log_row.details or {})
if not details.get("dispatch_status"):
details["dispatch_status"] = "deferred"
details["deferred_until"] = earliest.isoformat()
event_log_row.details = details
session.add(event_log_row)
defers_to_schedule.update(defers_for_event.values())
# Dispatch to targets. Isolate dispatcher exceptions per group so
# a failed remote call doesn't bubble out, abort the surrounding
# transaction, and roll back the just-written defers/event_log.
from ..services.http_session import get_http_session
dispatcher = NotificationDispatcher(session=await get_http_session())
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
for tc, target_configs in groups.values():
if not target_configs:
continue
shaped_event = apply_tracking_display_filters(event, tc)
if shaped_event is None:
continue
results = await dispatcher.dispatch(shaped_event, target_configs)
try:
results = await dispatcher.dispatch(shaped_event, target_configs)
except Exception as err: # noqa: BLE001
_LOGGER.exception(
"Dispatcher raised for tracker %d: %s", tracker.id, err,
)
continue
for r in results:
if r.get("success"):
dispatched += 1
@@ -226,6 +308,18 @@ async def _dispatch_webhook_event(
await session.commit()
# Schedule drain jobs OUTSIDE the DB session so an APScheduler hiccup
# can't roll back the persisted defer rows.
if defers_to_schedule:
from ..services.scheduler import schedule_deferred_drain
for fire_at in defers_to_schedule:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
return dispatched
@@ -554,41 +648,3 @@ async def generic_webhook(token: str, request: Request):
await log_session.commit()
return {"ok": True, "dispatched": dispatched}
def _build_target_groups(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[tuple[Any, list[TargetConfig]]]:
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
Targets sharing a TrackingConfig dispatch together so a single
``apply_tracking_display_filters`` pass can shape one event for the
whole group; targets with different TCs may see differently-shaped
events (e.g. one with favorites_only, one without).
"""
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
return list(groups.values())
@@ -1369,6 +1369,12 @@ _INDEXES: list[tuple[str, str, str]] = [
("ix_command_template_slot_config_id", "command_template_slot", "config_id"),
("ix_action_rule_action_id", "action_rule", "action_id"),
("ix_action_execution_action_started", "action_execution", "action_id, started_at DESC"),
# Deferred-dispatch drain: WHERE status = 'pending' AND fire_at <= ?
# ORDER BY fire_at. The composite (status, fire_at) is the only access
# pattern; an individual fire_at index isn't needed.
("ix_deferred_dispatch_status_fire_at", "deferred_dispatch", "status, fire_at"),
("ix_deferred_dispatch_link_id", "deferred_dispatch", "link_id"),
("ix_deferred_dispatch_event_log_id", "deferred_dispatch", "event_log_id"),
]
@@ -1397,6 +1403,95 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_deferred_dispatch_event_log_fk(engine: AsyncEngine) -> None:
"""Rebuild ``deferred_dispatch`` if its event_log FK lacks ON DELETE SET NULL.
Early builds of this feature created the table with a default ``NO ACTION``
FK on ``event_log_id``. The daily event_log cleanup deletes rows past the
retention horizon with SQLite's enforced foreign_keys PRAGMA, a pending
DeferredDispatch row pointing at an aging-out event_log row would block
the cleanup with an FK violation.
SQLite can't ALTER a constraint without rebuilding the table. The table
has zero rows in any prod install old enough to need this fix (the
feature shipped in the same release as this migration), so a drop +
recreate via ``create_all`` is safe.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
# Read the original CREATE TABLE SQL to see whether SET NULL is wired.
row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text(
"SELECT sql FROM sqlite_master "
"WHERE type='table' AND name='deferred_dispatch'"
)
).fetchone()
)
ddl = (row[0] or "") if row else ""
if "ON DELETE SET NULL" in ddl.upper():
return
# Confirm there's nothing to migrate — refuse to drop a populated
# table even though the schema was wrong. Better to leave a warning
# than to lose state.
count_row = await conn.run_sync(
lambda sync_conn: sync_conn.execute(
text("SELECT COUNT(*) FROM deferred_dispatch")
).fetchone()
)
if count_row and count_row[0]:
logger.warning(
"deferred_dispatch FK is missing ON DELETE SET NULL but the "
"table holds %d rows; not auto-dropping. Inspect manually.",
count_row[0],
)
return
await conn.execute(text("DROP TABLE deferred_dispatch"))
logger.info(
"Dropped deferred_dispatch (empty) so create_all rebuilds it "
"with ON DELETE SET NULL on event_log_id",
)
# Recreate the table from the SQLModel metadata in this same txn.
from sqlmodel import SQLModel
# Ensure the model is registered on metadata before we ask create_all
# to build it. Lazy import to avoid a circular at module load time.
from .models import DeferredDispatch # noqa: F401
await conn.run_sync(
SQLModel.metadata.create_all,
tables=[SQLModel.metadata.tables["deferred_dispatch"]],
)
async def migrate_deferred_dispatch_unique_pending(engine: AsyncEngine) -> None:
"""Add a partial unique index preventing duplicate pending defers.
Without this, two webhook handlers (or a webhook racing the watcher)
can both call ``_find_pending_asset_rows`` and find nothing, then both
INSERT defeating coalescing. The partial index makes the second
INSERT raise ``IntegrityError`` and the caller's transaction abort,
after which a retry will see the now-visible row.
SQLite has supported ``CREATE UNIQUE INDEX ... WHERE ...`` since 3.8.
Once the table exists this is safe to run on every boot.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "deferred_dispatch"):
return
try:
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS "
"ux_deferred_dispatch_pending "
"ON deferred_dispatch(link_id, collection_id, event_type) "
"WHERE status = 'pending'"
))
except Exception: # pragma: no cover — log and continue
logger.warning(
"Failed to create partial unique index on deferred_dispatch",
exc_info=True,
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import UniqueConstraint, Text
from sqlalchemy import ForeignKey, UniqueConstraint, Text
from sqlmodel import JSON, Column, Field, SQLModel
@@ -494,6 +494,64 @@ class CommandTrackerListener(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class DeferredDispatch(SQLModel, table=True):
"""A dispatch held back by quiet hours, waiting for the window to end.
One row per ``(link, event_type, collection_id)`` for asset events newly
arriving events for the same key coalesce into the existing row's
``event_payload`` (union of added/removed asset sets) instead of inserting
a duplicate row. Non-asset events (push, pr_opened, ups_*, ) get a fresh
row each time because they aren't logically cancellable.
At drain time the scheduler picks up rows where ``status='pending'`` and
``fire_at <= now``, re-resolves the link/target/config against current
state (so subsequent config edits apply), and dispatches.
"""
__tablename__ = "deferred_dispatch"
id: int | None = Field(default=None, primary_key=True)
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
tracker_id: int = Field(foreign_key="notification_tracker.id", index=True)
# The specific link this deferral targets. On drain we re-fetch by ID; if
# the link was disabled or removed in the meantime we drop with a
# ``deferred_then_dropped`` log row instead of dispatching to nothing.
link_id: int = Field(
foreign_key="notification_tracker_target.id", index=True,
)
# The event_log row written when the event was first detected. The drain
# writes a follow-up event_log row referencing this id so the dashboard
# can show "delivered at HH:MM, originally detected at HH:MM".
#
# ``ondelete="SET NULL"`` matters because the daily ``_cleanup_old_events``
# job hard-deletes event_log rows past the retention horizon. Without
# SET NULL, an old pending DeferredDispatch row referencing an aging-out
# event_log row would either (a) prevent the delete with an FK violation
# under SQLite's enforced foreign_keys PRAGMA, or (b) leave a dangling
# reference on engines that don't enforce.
event_log_id: int | None = Field(
default=None,
sa_column=Column(
"event_log_id",
ForeignKey("event_log.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
event_type: str = Field(index=True)
collection_id: str = Field(default="", index=True)
# ``dataclasses.asdict(ServiceEvent)`` with datetime/enum normalisation —
# round-tripped via the helpers in ``services.deferred_dispatch``.
event_payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
fire_at: datetime = Field(index=True)
# ``pending`` until the drain runs; then ``fired``, ``dropped`` (link
# gone / event-type disabled after defer), or ``cancelled`` (coalesced
# away by a counter-event).
status: str = Field(default="pending", index=True)
fired_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
class EventLog(SQLModel, table=True):
"""Log of detected events."""
@@ -76,6 +76,8 @@ async def lifespan(app: FastAPI):
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_deferred_dispatch_event_log_fk,
migrate_deferred_dispatch_unique_pending,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -100,6 +102,11 @@ async def lifespan(app: FastAPI):
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
# FK-rebuild MUST run before the unique-index creation: drop+create_all
# of deferred_dispatch wipes its indexes; the next migration re-establishes
# the partial unique index.
await migrate_deferred_dispatch_event_log_fk(engine)
await migrate_deferred_dispatch_unique_pending(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -147,11 +154,8 @@ async def lifespan(app: FastAPI):
await dispose_engine()
try:
from importlib.metadata import version as _pkg_version
_APP_VERSION = _pkg_version("notify-bridge-server")
except Exception: # pragma: no cover — editable install edge cases
_APP_VERSION = "0.0.0+unknown"
from .version import resolve_version as _resolve_version
_APP_VERSION = _resolve_version()
app = FastAPI(title="Notify Bridge", version=_APP_VERSION, lifespan=lifespan)
@@ -0,0 +1,798 @@
"""Deferred-dispatch infrastructure for quiet-hours notifications.
When ``evaluate_event_gate`` returns ``QUIET_HOURS`` for a deferrable event
type, the dispatch site calls :func:`defer_event` instead of dropping. That
either inserts a new ``DeferredDispatch`` row or coalesces the event into an
existing pending row for the same ``(link_id, collection_id)`` asset add
+ matching remove cancels out, asset add + asset add merges set-union.
An APScheduler one-shot ``date`` job per quiet-window-end fires
:func:`drain_deferred_due` which:
1. Re-resolves each pending row's link/target/configs against current state.
2. Drops rows whose link/target was deleted or disabled in the meantime.
3. Re-checks quiet hours (in case the user extended the window mid-flight)
and pushes ``fire_at`` to the new end if still suppressed.
4. Dispatches via the existing ``NotificationDispatcher``.
5. Writes a follow-up ``event_log`` row referencing the original
``event_log_id`` so the dashboard shows "delivered late".
Wall-clock event types (``scheduled_message``) are explicitly NOT in
``_DEFERRABLE_EVENT_TYPES`` delivering a "good morning" memory at 3 pm is
worse than dropping it. Those keep the legacy drop-on-quiet-hours behavior.
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
from datetime import datetime, timezone
from typing import Any
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.notifications.dispatcher import (
NotificationDispatcher,
TargetConfig,
)
from notify_bridge_core.providers.base import ServiceProviderType
from ..database.engine import get_engine
from ..database.models import (
DeferredDispatch,
EventLog,
NotificationTracker,
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Policy
# ---------------------------------------------------------------------------
# Change-driven event types that are safe to deliver after the quiet window
# ends — the underlying state change (a photo was added, a PR was opened, the
# UPS went on battery) remains relevant even hours later. Wall-clock event
# types (``scheduled_message``) are deliberately excluded: a "good morning"
# delivered at 3 pm is wrong, drop is more correct than late delivery.
_DEFERRABLE_EVENT_TYPES: frozenset[str] = frozenset({
# Immich
"assets_added", "assets_removed",
"collection_renamed", "collection_deleted", "sharing_changed",
# Gitea
"push",
"issue_opened", "issue_closed", "issue_commented",
"pr_opened", "pr_closed", "pr_merged", "pr_commented",
"release_published",
# Planka
"card_created", "card_updated", "card_moved", "card_deleted",
"card_commented", "comment_updated",
"board_created", "board_updated", "board_deleted",
"list_created", "list_updated", "list_deleted",
"attachment_created", "card_label_added", "task_completed",
# Generic webhook
"webhook_received",
# NUT (UPS)
"ups_online", "ups_on_battery", "ups_low_battery",
"ups_battery_restored", "ups_comms_lost", "ups_comms_restored",
"ups_replace_battery", "ups_overload",
})
# Per-tracker cap on the pending queue. A misconfigured short quiet window
# plus a chatty upstream (e.g. mass-imported album) could otherwise grow
# unbounded. On overflow we drop oldest (FIFO) — recent events still survive
# to be delivered, ancient ones are sacrificed.
_MAX_PENDING_PER_TRACKER = 1000
# Per-row timeout in the drain. Without this, a single hanging Telegram/SMTP
# call could stall the whole drain for hours and leave the rest of the queue
# stranded. Generous because legitimate large media uploads can take minutes.
_DRAIN_DISPATCH_TIMEOUT_SECONDS = 120
def is_deferrable(event_type: str) -> bool:
"""Whether this event type should be deferred (vs. dropped) during quiet hours."""
return event_type in _DEFERRABLE_EVENT_TYPES
# ---------------------------------------------------------------------------
# ServiceEvent (de)serialization
# ---------------------------------------------------------------------------
#
# JSON column stores ``dataclasses.asdict(event)`` plus a normalisation pass
# for datetimes (ISO strings) and enums (string values). Round-trip via the
# reverse pass below.
def _normalize_for_json(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (EventType, MediaType, ServiceProviderType)):
return value.value
if isinstance(value, dict):
return {k: _normalize_for_json(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_for_json(v) for v in value]
return value
def serialize_event(event: ServiceEvent) -> dict[str, Any]:
"""Convert a ``ServiceEvent`` to a JSON-safe dict for ``DeferredDispatch.event_payload``."""
return _normalize_for_json(dataclasses.asdict(event))
def _parse_dt(s: Any) -> datetime:
if isinstance(s, datetime):
return s
return datetime.fromisoformat(s)
def _deserialize_asset(data: dict[str, Any]) -> MediaAsset:
return MediaAsset(
id=data["id"],
type=MediaType(data["type"]),
filename=data["filename"],
created_at=_parse_dt(data["created_at"]),
owner_name=data.get("owner_name"),
description=data.get("description"),
tags=list(data.get("tags") or []),
thumbnail_url=data.get("thumbnail_url"),
preview_url=data.get("preview_url"),
full_url=data.get("full_url"),
extra=dict(data.get("extra") or {}),
)
def deserialize_event(data: dict[str, Any]) -> ServiceEvent:
"""Inverse of :func:`serialize_event`."""
return ServiceEvent(
event_type=EventType(data["event_type"]),
provider_type=ServiceProviderType(data["provider_type"]),
provider_name=data["provider_name"],
collection_id=data["collection_id"],
collection_name=data["collection_name"],
timestamp=_parse_dt(data["timestamp"]),
added_assets=[_deserialize_asset(a) for a in data.get("added_assets") or []],
removed_asset_ids=list(data.get("removed_asset_ids") or []),
added_count=int(data.get("added_count") or 0),
removed_count=int(data.get("removed_count") or 0),
old_name=data.get("old_name"),
new_name=data.get("new_name"),
old_shared=data.get("old_shared"),
new_shared=data.get("new_shared"),
extra=dict(data.get("extra") or {}),
)
# ---------------------------------------------------------------------------
# Coalescing
# ---------------------------------------------------------------------------
def _added_ids(payload: dict[str, Any]) -> list[str]:
return [a["id"] for a in payload.get("added_assets") or [] if "id" in a]
def _coalesce_assets_added(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Apply add-then-remove cancellation and add-then-add union.
Returns ``(action, updated_added_row, updated_removed_row)`` where action
is one of ``"insert"`` (caller must create a new row), ``"merge"`` (update
existing rows in place caller must session.add them).
"""
new_ids = [a.id for a in new_event.added_assets]
new_ids_set = set(new_ids)
# 1) If a matching assets_removed row pending: subtract — that's a re-add.
if existing_removed_row is not None:
removed_ids = list(existing_removed_row.event_payload.get("removed_asset_ids") or [])
kept = [rid for rid in removed_ids if rid not in new_ids_set]
if len(kept) != len(removed_ids):
payload = dict(existing_removed_row.event_payload)
payload["removed_asset_ids"] = kept
payload["removed_count"] = len(kept)
existing_removed_row.event_payload = payload
if not kept:
# All previously-removed IDs are being re-added → entire
# removal is cancelled. Mark for caller to delete.
existing_removed_row.status = "cancelled"
# The intersection re-adds are accounted for by the cancellation;
# remaining new IDs (those NOT in removed list) still need to land
# in the assets_added row.
new_ids = [nid for nid in new_ids if nid not in set(removed_ids)]
new_ids_set = set(new_ids)
if not new_ids:
# All new added IDs cancelled an existing remove → nothing to enqueue.
return ("merge", None, existing_removed_row)
if existing_added_row is None:
return ("insert", None, existing_removed_row)
# 2) Union with existing assets_added — earliest fire_at wins.
payload = dict(existing_added_row.event_payload)
existing_assets = list(payload.get("added_assets") or [])
seen = {a.get("id") for a in existing_assets}
new_serialized = serialize_event(new_event)
for a in new_serialized.get("added_assets") or []:
if a.get("id") in new_ids_set and a.get("id") not in seen:
existing_assets.append(a)
seen.add(a.get("id"))
payload["added_assets"] = existing_assets
payload["added_count"] = len(existing_assets)
existing_added_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
def _coalesce_assets_removed(
new_event: ServiceEvent,
existing_added_row: DeferredDispatch | None,
existing_removed_row: DeferredDispatch | None,
) -> tuple[str, DeferredDispatch | None, DeferredDispatch | None]:
"""Mirror of :func:`_coalesce_assets_added` for removal events."""
new_ids = list(new_event.removed_asset_ids)
new_ids_set = set(new_ids)
# 1) If a matching assets_added row pending: subtract — that's an
# add-then-remove within the window, cancel both sides.
if existing_added_row is not None:
added = list(existing_added_row.event_payload.get("added_assets") or [])
kept_assets = [a for a in added if a.get("id") not in new_ids_set]
if len(kept_assets) != len(added):
payload = dict(existing_added_row.event_payload)
payload["added_assets"] = kept_assets
payload["added_count"] = len(kept_assets)
existing_added_row.event_payload = payload
if not kept_assets:
existing_added_row.status = "cancelled"
# IDs that were just added during the window don't need to flow
# into the assets_removed row — they're a wash.
cancelled_ids = {a.get("id") for a in added if a.get("id") in new_ids_set}
new_ids = [nid for nid in new_ids if nid not in cancelled_ids]
new_ids_set = set(new_ids)
if not new_ids:
return ("merge", existing_added_row, None)
if existing_removed_row is None:
return ("insert", existing_added_row, None)
# 2) Union with existing assets_removed — earliest fire_at wins.
payload = dict(existing_removed_row.event_payload)
existing_ids = list(payload.get("removed_asset_ids") or [])
seen = set(existing_ids)
for rid in new_ids:
if rid not in seen:
existing_ids.append(rid)
seen.add(rid)
payload["removed_asset_ids"] = existing_ids
payload["removed_count"] = len(existing_ids)
existing_removed_row.event_payload = payload
return ("merge", existing_added_row, existing_removed_row)
async def _find_pending_asset_rows(
session: AsyncSession,
link_id: int,
collection_id: str,
) -> tuple[DeferredDispatch | None, DeferredDispatch | None]:
"""Return ``(assets_added_row, assets_removed_row)`` pending for this link+collection."""
result = await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.link_id == link_id,
DeferredDispatch.collection_id == collection_id,
DeferredDispatch.status == "pending",
DeferredDispatch.event_type.in_(["assets_added", "assets_removed"]),
)
)
added_row: DeferredDispatch | None = None
removed_row: DeferredDispatch | None = None
for row in result.all():
if row.event_type == "assets_added":
added_row = row
elif row.event_type == "assets_removed":
removed_row = row
return added_row, removed_row
async def _trim_queue_if_needed(
session: AsyncSession,
tracker_id: int,
) -> None:
"""Drop oldest pending rows beyond the per-tracker cap with a log row each.
Loads the parent tracker so the emitted event_log rows carry proper
``tracker_name``/``provider_id``/``provider_name`` and slot into the
dashboard's "by tracker" grouping — without these the drop rows show up
under an unattributed bucket and confuse the audit trail.
"""
rows = (await session.exec(
select(DeferredDispatch).where(
DeferredDispatch.tracker_id == tracker_id,
DeferredDispatch.status == "pending",
).order_by(DeferredDispatch.fire_at.asc(), DeferredDispatch.id.asc())
)).all()
overflow = len(rows) - _MAX_PENDING_PER_TRACKER
if overflow <= 0:
return
_LOGGER.warning(
"Deferred queue for tracker %d exceeds cap (%d > %d); dropping %d oldest",
tracker_id, len(rows), _MAX_PENDING_PER_TRACKER, overflow,
)
tracker = await session.get(NotificationTracker, tracker_id)
tracker_name = tracker.name if tracker else ""
provider_id = tracker.provider_id if tracker else None
provider_name = ""
if tracker is not None and provider_id is not None:
provider = await session.get(ServiceProvider, provider_id)
if provider is not None:
provider_name = provider.name
for row in rows[:overflow]:
await _mark_dropped(
session, row,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
reason="queue_overflow",
)
# ---------------------------------------------------------------------------
# Enqueue (called from dispatch sites when gate returns QUIET_HOURS)
# ---------------------------------------------------------------------------
async def defer_event(
session: AsyncSession,
*,
event: ServiceEvent,
user_id: int | None,
tracker_id: int,
link_id: int,
event_log_id: int | None,
fire_at: datetime,
) -> str:
"""Persist a deferred dispatch (or coalesce into an existing one).
Caller is responsible for committing the session. Returns one of:
* ``"inserted"`` a fresh DeferredDispatch row was created.
* ``"merged"`` coalesced into an existing row (union or partial cancel).
* ``"cancelled"`` the new event fully cancelled an existing pending one
(add-then-remove or remove-then-readd of the same asset IDs). Both sides
are gone after this call.
* ``"non_deferrable"`` event type is wall-clock; caller should drop it
with a ``"suppressed_quiet_hours_nondeferrable"`` event_log row.
"""
event_type = event.event_type.value
if not is_deferrable(event_type):
return "non_deferrable"
fire_at_utc = fire_at.astimezone(timezone.utc) if fire_at.tzinfo else fire_at.replace(tzinfo=timezone.utc)
# Asset events get set-merging across the same link+collection. Everything
# else just gets a new row — those events aren't naturally cancellable.
if event_type in ("assets_added", "assets_removed"):
added_row, removed_row = await _find_pending_asset_rows(
session, link_id, event.collection_id,
)
if event_type == "assets_added":
action, upd_added, upd_removed = _coalesce_assets_added(
event, added_row, removed_row,
)
else:
action, upd_added, upd_removed = _coalesce_assets_removed(
event, added_row, removed_row,
)
# Apply pending updates. ``status="cancelled"`` rows are deleted
# outright so the drain doesn't see them.
fully_cancelled = False
for row in (upd_added, upd_removed):
if row is None:
continue
if row.status == "cancelled":
await session.delete(row)
fully_cancelled = True
else:
session.add(row)
if action == "insert":
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# action == "merge" — either updated existing or fully cancelled.
return "cancelled" if fully_cancelled and (upd_added is None or upd_added.status == "cancelled") and (upd_removed is None or upd_removed.status == "cancelled") else "merged"
# Non-asset event: no coalescing, fresh row.
new_row = DeferredDispatch(
user_id=user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
event_type=event_type,
collection_id=event.collection_id,
event_payload=serialize_event(event),
fire_at=fire_at_utc,
status="pending",
)
session.add(new_row)
await _trim_queue_if_needed(session, tracker_id)
return "inserted"
# ---------------------------------------------------------------------------
# Drain (called by APScheduler date job at quiet_hours_end_at)
# ---------------------------------------------------------------------------
async def drain_deferred_due(now: datetime | None = None) -> dict[str, int]:
"""Dispatch all pending DeferredDispatch rows whose ``fire_at <= now``.
Re-resolves link/target/configs against current DB state so config edits
between suppression and drain time take effect. Returns a small stats
dict for logging.
Implementation note: rows are *re-fetched* by id inside each per-tracker
session rather than carried across session boundaries. Carrying a row
instance to a new session and calling ``session.add(row)`` on a detached
PK-bearing instance triggers an INSERT (collision with the existing PK)
on flush a class of bug that's invisible until the first session
closes, hence the up-front re-fetch.
"""
now_utc = (now or datetime.now(timezone.utc))
if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=timezone.utc)
stats = {"fired": 0, "dropped": 0, "rescheduled": 0, "errors": 0}
engine = get_engine()
async with AsyncSession(engine) as session:
# Only pull the row identity + grouping key. Loading the full ORM
# objects in a session that's about to close just wastes work — we
# re-fetch fresh attached instances in the per-tracker session below.
ident_rows = (await session.exec(
select(DeferredDispatch.id, DeferredDispatch.tracker_id).where(
DeferredDispatch.status == "pending",
DeferredDispatch.fire_at <= now_utc,
).order_by(DeferredDispatch.fire_at.asc())
)).all()
if not ident_rows:
_LOGGER.debug("drain_deferred_due: no pending rows due")
return stats
_LOGGER.info(
"Draining %d deferred dispatches due at %s",
len(ident_rows), now_utc.isoformat(),
)
# Group by tracker so a single per-tracker session can re-fetch its rows
# (attached) and re-resolve link state once.
ids_by_tracker: dict[int, list[int]] = {}
for row_id, tracker_id in ident_rows:
if row_id is None:
continue
ids_by_tracker.setdefault(tracker_id, []).append(row_id)
from .watcher import _get_telegram_caches
from .http_session import get_http_session
url_cache, asset_cache = await _get_telegram_caches()
shared_session = await get_http_session()
dispatcher = NotificationDispatcher(
url_cache=url_cache, asset_cache=asset_cache, session=shared_session,
)
for tracker_id, row_ids in ids_by_tracker.items():
async with AsyncSession(engine) as session:
tracker = await session.get(NotificationTracker, tracker_id)
# Re-fetch rows freshly attached to THIS session.
rows = (await session.exec(
select(DeferredDispatch).where(DeferredDispatch.id.in_(row_ids))
)).all()
if tracker is None or not tracker.enabled:
# Tracker deleted or disabled between defer and drain — drop
# all pending rows for it. Disable matches the live-path
# invariant (watcher / webhooks / scheduled_dispatch all
# short-circuit when ``tracker.enabled`` is False).
reason = "tracker_removed" if tracker is None else "tracker_disabled_after_defer"
for row in rows:
await _mark_dropped(
session, row,
tracker=tracker, reason=reason,
)
stats["dropped"] += 1
await session.commit()
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
provider_config = dict(provider.config) if provider else {}
provider_id = provider.id if provider else tracker.provider_id
provider_name = provider.name if provider else ""
app_tz = await get_app_timezone(session)
# Reload current link state. Broadcast links emit ONE entry per
# child target sharing the SAME parent ``link_id`` — a plain
# ``{link_id: ld}`` dict would silently drop N-1 children. The
# drain dispatches to every expanded entry for the parent.
link_data = await load_link_data(session, tracker_id)
link_by_id: dict[int, list[dict[str, Any]]] = {}
for ld in link_data:
key = ld.get("link_id")
if key is None:
continue
link_by_id.setdefault(key, []).append(ld)
for row in rows:
try:
await _process_row(
session, row, tracker, provider_id, provider_name,
provider_config, app_tz, link_by_id, dispatcher, stats,
)
except Exception as err: # noqa: BLE001 — keep draining other rows
_LOGGER.exception(
"Drain failed for deferred dispatch id=%s: %s", row.id, err,
)
stats["errors"] += 1
await session.commit()
_LOGGER.info("Drain complete: %s", stats)
return stats
async def _mark_dropped(
session: AsyncSession,
row: DeferredDispatch,
*,
tracker: NotificationTracker | None = None,
tracker_name: str = "",
provider_id: int | None = None,
provider_name: str = "",
reason: str,
) -> None:
"""Record a drop on the deferred row and emit a follow-up event_log entry.
``tracker``/``tracker_name``/``provider_id``/``provider_name`` populate
the new event_log row's owner/provider columns so the dashboard "by
tracker" grouping works for the drop path. Without these the row would
have empty strings and slot into the "unknown" bucket.
"""
if tracker is not None:
tracker_name = tracker_name or tracker.name
if provider_id is None:
provider_id = tracker.provider_id
payload = row.event_payload if isinstance(row.event_payload, dict) else {}
row.status = "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=payload.get("collection_name", ""),
assets_count=int(payload.get("added_count", 0))
or int(payload.get("removed_count", 0)),
details={
"dispatch_status": "deferred_then_dropped",
"reason": reason,
"original_event_log_id": row.event_log_id,
"provider_type": payload.get("provider_type", ""),
},
))
async def _process_row(
session: AsyncSession,
row: DeferredDispatch,
tracker: NotificationTracker,
provider_id: int,
provider_name: str,
provider_config: dict[str, Any],
app_tz: str,
link_by_id: dict[int, list[dict[str, Any]]],
dispatcher: NotificationDispatcher,
stats: dict[str, int],
) -> None:
"""Drain a single row: re-resolve link, re-evaluate gate, dispatch.
``link_by_id`` maps parent link_id list of expanded entries (one per
broadcast child, or a single-element list for regular targets). Every
entry produces its own target_config so a broadcast deferred row fans
out to all current children at drain time.
"""
expanded = link_by_id.get(row.link_id)
if not expanded:
# Link removed/disabled between defer and drain.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="link_removed",
)
stats["dropped"] += 1
return
# Every expanded entry for a parent link shares the same tracking_config,
# so the gate decision and ``apply_tracking_display_filters`` shaping are
# made once. Only the target_configs differ across children.
tc = expanded[0].get("tracking_config")
event = deserialize_event(row.event_payload)
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="event_type_disabled_after_defer",
)
stats["dropped"] += 1
return
if outcome.reason is GateReason.QUIET_HOURS and outcome.quiet_hours_end_at is not None:
row.fire_at = outcome.quiet_hours_end_at
session.add(row)
stats["rescheduled"] += 1
try:
from .scheduler import schedule_deferred_drain
schedule_deferred_drain(outcome.quiet_hours_end_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to reschedule drain for %s", outcome.quiet_hours_end_at,
)
return
shaped = apply_tracking_display_filters(event, tc)
if shaped is None:
# ``notify_favorites_only`` (or another display filter) dropped every
# asset from the event. Inconsistent earlier behavior swallowed this
# silently; we now route through the same "dropped + event_log"
# pathway as link_removed so the dashboard shows why.
await _mark_dropped(
session, row,
tracker=tracker, provider_id=provider_id, provider_name=provider_name,
reason="filtered_after_defer",
)
stats["dropped"] += 1
return
# Build one target_config per expanded child (regular targets → length 1;
# broadcast → length N children).
target_configs: list[TargetConfig] = []
for ld in expanded:
tmpl = ld.get("template_config")
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld.get("template_slots"),
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=(tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y"),
provider_api_key=provider_config.get("api_key") or provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", "") or provider_config.get("url", ""),
receivers=ld["receivers"],
))
# Per-row timeout — a single hanging remote call (Telegram outage, slow
# SMTP) must not stall the rest of the queue.
try:
results = await asyncio.wait_for(
dispatcher.dispatch(shaped, target_configs),
timeout=_DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
_LOGGER.warning(
"Drain dispatch for row %s timed out after %ds",
row.id, _DRAIN_DISPATCH_TIMEOUT_SECONDS,
)
results = [{"success": False, "error": f"timeout after {_DRAIN_DISPATCH_TIMEOUT_SECONDS}s"}]
success = any(r.get("success") for r in results)
row.status = "fired" if success else "dropped"
row.fired_at = datetime.now(timezone.utc)
session.add(row)
if success:
stats["fired"] += 1
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "delivered_after_quiet_hours",
"original_event_log_id": row.event_log_id,
"deferred_for_seconds": int(
(row.fired_at - row.created_at).total_seconds()
),
"provider_type": event.provider_type.value,
},
))
else:
stats["dropped"] += 1
first_err = next((r.get("error") for r in results if not r.get("success")), "unknown")
session.add(EventLog(
user_id=row.user_id,
tracker_id=row.tracker_id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=row.event_type,
collection_id=row.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or event.removed_count or 0,
details={
"dispatch_status": "deferred_then_failed",
"reason": str(first_err)[:200],
"original_event_log_id": row.event_log_id,
"provider_type": event.provider_type.value,
},
))
# ---------------------------------------------------------------------------
# Startup: reschedule pending drain jobs found in the DB
# ---------------------------------------------------------------------------
async def load_pending_drain_jobs() -> int:
"""At startup, scan ``DeferredDispatch`` for pending rows and (re)schedule drains.
Rows whose ``fire_at`` already passed get a single immediate-fire job; the
rest get one job per distinct ``fire_at`` (minute-rounded) so all rows
sharing a window end share a drain.
"""
from .scheduler import schedule_deferred_drain
engine = get_engine()
async with AsyncSession(engine) as session:
rows = (await session.exec(
select(DeferredDispatch.fire_at).where(
DeferredDispatch.status == "pending",
)
)).all()
if not rows:
return 0
unique_fire_ats: set[datetime] = set()
for fa in rows:
if isinstance(fa, datetime):
unique_fire_ats.add(fa.astimezone(timezone.utc) if fa.tzinfo else fa.replace(tzinfo=timezone.utc))
for fa in unique_fire_ats:
schedule_deferred_drain(fa)
_LOGGER.info(
"Loaded %d pending deferred dispatches; scheduled %d drain job(s)",
len(rows), len(unique_fire_ats),
)
return len(unique_fire_ats)
@@ -5,7 +5,9 @@ from __future__ import annotations
import dataclasses
import logging
import random
from datetime import datetime, time, timezone
from dataclasses import dataclass
from datetime import datetime, time, timedelta, timezone
from enum import Enum
from typing import Any, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -33,6 +35,35 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__)
class GateReason(str, Enum):
"""Why ``evaluate_event_gate`` allowed or blocked a dispatch.
String-backed so it can be persisted in ``EventLog.details`` JSON and
round-trip cleanly.
"""
ALLOWED = "allowed"
EVENT_TYPE_DISABLED = "event_type_disabled"
QUIET_HOURS = "quiet_hours"
@dataclass(frozen=True)
class GateOutcome:
"""Result of evaluating a (event, tracking_config) pair against dispatch gates.
``quiet_hours_end_at`` is set iff ``reason == QUIET_HOURS`` and gives the
UTC datetime at which the current quiet window ends used by the
deferred-dispatch scheduler to know when to fire the held notification.
"""
reason: GateReason
quiet_hours_end_at: datetime | None = None
@property
def allowed(self) -> bool:
return self.reason is GateReason.ALLOWED
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
@@ -44,6 +75,59 @@ def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
return ZoneInfo("UTC")
def quiet_hours_status(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> datetime | None:
"""Return the UTC datetime when the current quiet window ends, or None.
Returns ``None`` when:
* either bound is missing,
* the bounds are malformed,
* the current local time is outside the configured window.
Returns a UTC ``datetime`` aligned to ``HH:MM`` (seconds=0, microseconds=0)
representing the next end-of-window moment after "now" when the current
time IS inside the window. For overnight windows (e.g. 22:00-06:00) the
end may be tomorrow.
"""
if not start or not end:
return None
try:
tz = _resolve_zoneinfo(tz_name)
now_local = datetime.now(timezone.utc).astimezone(tz)
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
except (ValueError, TypeError):
return None
# ``start == end`` (e.g. "00:00-00:00") has no consistent meaning: under
# the normal-window branch the window is one instant wide; under the
# overnight-window branch it's effectively always-on. Either is almost
# certainly a user mistake, so treat it as "no window configured" rather
# than silently deferring every notification all day.
if t_start == t_end:
return None
now_t = now_local.time()
if t_start <= t_end:
in_window = t_start <= now_t <= t_end
else:
in_window = now_t >= t_start or now_t <= t_end
if not in_window:
return None
end_today = now_local.replace(
hour=t_end.hour, minute=t_end.minute, second=0, microsecond=0,
)
# If today's end already passed (overnight window, post-midnight half),
# the actual end is tomorrow at the same wall-clock time.
if end_today <= now_local:
end_today = end_today + timedelta(days=1)
return end_today.astimezone(timezone.utc)
def in_quiet_hours(
start: str | None,
end: str | None,
@@ -51,23 +135,12 @@ def in_quiet_hours(
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
Thin wrapper over ``quiet_hours_status`` preserved for back-compat with
callers that only need the boolean. New code should prefer
``quiet_hours_status`` (or ``evaluate_event_gate``) when the window end
time matters.
"""
if not start or not end:
return False
try:
tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
return t_start <= now <= t_end
else:
# Overnight window (e.g., 22:00 - 06:00)
return now >= t_start or now <= t_end
except (ValueError, TypeError):
return False
return quiet_hours_status(start, end, tz_name) is not None
async def get_app_timezone(session: AsyncSession) -> str:
@@ -77,18 +150,13 @@ async def get_app_timezone(session: AsyncSession) -> str:
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
def _event_type_enabled(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Return True iff the tracking config's per-event-type flag allows this event.
Quiet hours are NOT considered here this is the user's "do I care about
this kind of event at all" gate. See ``evaluate_event_gate`` for the
combined gate that also folds in quiet hours.
"""
event_type = event.event_type.value
flag_map = {
# Immich events
@@ -140,6 +208,52 @@ def event_allowed_by_config(
return flag_map.get(event_type, True)
def evaluate_event_gate(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> GateOutcome:
"""Decide whether an event should dispatch through the given tracking config.
Returns a :class:`GateOutcome` carrying both the verdict and when blocked
by quiet hours the UTC datetime at which the window ends so the caller
can schedule a deferred dispatch.
Order of checks: quiet hours first, then per-event-type flag. Quiet hours
is the "louder" gate (it applies to every type), so reporting it first
avoids the surprising case of "you disabled this event type" showing up
when the user really just opened the quiet window.
"""
if tc.quiet_hours_enabled:
end_at = quiet_hours_status(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name,
)
if end_at is not None:
return GateOutcome(
reason=GateReason.QUIET_HOURS,
quiet_hours_end_at=end_at,
)
if not _event_type_enabled(event, tc):
return GateOutcome(reason=GateReason.EVENT_TYPE_DISABLED)
return GateOutcome(reason=GateReason.ALLOWED)
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Boolean back-compat wrapper around :func:`evaluate_event_gate`.
New call sites should use ``evaluate_event_gate`` directly so they can
distinguish a quiet-hours suppression (deferrable) from an event-type
disable (drop forever).
"""
return evaluate_event_gate(event, tc, tz_name).allowed
# --- Display-time filters driven by TrackingConfig -------------------------
#
# These transform a ServiceEvent so the dispatched notification reflects the
@@ -472,6 +586,7 @@ async def load_link_data(
resolved = await _resolve_target(session, child_target)
link_data.append({
**resolved,
"link_id": tt.id,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
@@ -482,6 +597,7 @@ async def load_link_data(
resolved = await _resolve_target(session, target)
link_data.append({
**resolved,
"link_id": tt.id,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
@@ -0,0 +1,295 @@
"""Upstream release-check service.
Reads the configured release provider, asks it for the latest upstream release,
and caches the result into :class:`AppSetting` rows so the API can serve the
status without re-hitting the network. All failures are swallowed and surfaced
through ``release_error`` the server must stay up even if Gitea is down.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import aiohttp
from notify_bridge_core.release import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
build_release_provider,
)
from notify_bridge_core.release.base import is_newer
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
from ..database.models import AppSetting
_LOGGER = logging.getLogger(__name__)
# Cached-state AppSetting keys (read by the API, written by the checker).
KEY_LATEST_TAG = "release_latest_tag"
KEY_LATEST_VERSION = "release_latest_version"
KEY_LATEST_URL = "release_latest_url"
KEY_LATEST_BODY = "release_latest_body"
KEY_LATEST_NAME = "release_latest_name"
KEY_LATEST_PUBLISHED_AT = "release_latest_published_at"
KEY_LATEST_PRERELEASE = "release_latest_prerelease"
KEY_CHECKED_AT = "release_checked_at"
KEY_ERROR = "release_error"
# Operator-configured keys.
KEY_PROVIDER_KIND = "release_provider_kind"
KEY_PROVIDER_URL = "release_provider_url"
KEY_PROVIDER_REPO = "release_provider_repo"
KEY_INCLUDE_PRERELEASES = "release_include_prereleases"
KEY_CHECK_INTERVAL_HOURS = "release_check_interval_hours"
# Allowed range for the interval (matches the UI hint).
INTERVAL_MIN_HOURS = 1
INTERVAL_MAX_HOURS = 168
# Minimum gap between checks. Independent of the configured interval — a flood
# of /release/check API calls or scheduler misfires can't push real load on
# upstream Gitea within this window.
_MIN_CHECK_INTERVAL = timedelta(seconds=30)
# Serialises concurrent run_check invocations (scheduled job + manual force
# check + provider-changed save can all fire close together).
_run_lock = asyncio.Lock()
_CACHED_KEYS = (
KEY_LATEST_TAG,
KEY_LATEST_VERSION,
KEY_LATEST_URL,
KEY_LATEST_BODY,
KEY_LATEST_NAME,
KEY_LATEST_PUBLISHED_AT,
KEY_LATEST_PRERELEASE,
KEY_CHECKED_AT,
KEY_ERROR,
)
@dataclass(frozen=True)
class ReleaseStatus:
"""Snapshot returned by :func:`load_status` and friends."""
provider: str
current: str
latest: str | None
latest_tag: str | None
latest_url: str | None
latest_body: str | None
latest_name: str | None
latest_published_at: str | None
latest_prerelease: bool
checked_at: str | None
update_available: bool
error: str | None
def _server_version() -> str:
"""Resolve the running server version (delegates to the shared helper).
Routed through :mod:`notify_bridge_server.version` so the "current" the
UI reports matches `/api/health` and is robust to stale editable installs.
"""
from ..version import resolve_version
return resolve_version()
def parse_interval_hours(raw: str | None, default: int = 12) -> int:
"""Clamp/parse the interval setting into a sensible integer."""
try:
value = int((raw or "").strip() or default)
except (TypeError, ValueError):
return default
return max(INTERVAL_MIN_HOURS, min(INTERVAL_MAX_HOURS, value))
def _coerce_provider_kind(raw: str | None) -> str:
"""Normalise the stored kind to a known enum value (default: disabled)."""
try:
return ReleaseProviderKind(raw or "").value
except ValueError:
return ReleaseProviderKind.DISABLED.value
async def load_status() -> ReleaseStatus:
"""Read the latest cached status without performing a network call."""
async with AsyncSession(get_engine()) as session:
provider = await get_setting(session, KEY_PROVIDER_KIND)
latest_tag = await get_setting(session, KEY_LATEST_TAG)
latest_version = await get_setting(session, KEY_LATEST_VERSION)
latest_url = await get_setting(session, KEY_LATEST_URL)
latest_body = await get_setting(session, KEY_LATEST_BODY)
latest_name = await get_setting(session, KEY_LATEST_NAME)
latest_published_at = await get_setting(session, KEY_LATEST_PUBLISHED_AT)
latest_prerelease = await get_setting(session, KEY_LATEST_PRERELEASE)
checked_at = await get_setting(session, KEY_CHECKED_AT)
error = await get_setting(session, KEY_ERROR)
current = _server_version()
has_latest = bool(latest_version)
update_available = bool(has_latest and is_newer(latest_version, current))
return ReleaseStatus(
provider=_coerce_provider_kind(provider),
current=current,
latest=latest_version or None,
latest_tag=latest_tag or None,
latest_url=latest_url or None,
latest_body=latest_body or None,
latest_name=latest_name or None,
latest_published_at=latest_published_at or None,
latest_prerelease=latest_prerelease == "1",
checked_at=checked_at or None,
update_available=update_available,
error=error or None,
)
async def run_check(*, force: bool = False) -> ReleaseStatus:
"""Hit the configured provider and persist the result, then return it.
Args:
force: bypass the per-process rate limit. Used by the manual
"Check now" admin action; the scheduled probe never forces.
"""
async with _run_lock:
return await _run_check_locked(force=force)
async def _run_check_locked(*, force: bool) -> ReleaseStatus:
from .http_session import get_http_session
# Throttle: if the last check landed within _MIN_CHECK_INTERVAL and the
# caller didn't ask for force, skip the network round-trip and return the
# cached status. Force is still gated by the lock above, so an abusive
# admin spamming /release/check serialises to one in-flight at a time.
if not force:
async with AsyncSession(get_engine()) as session:
last = await get_setting(session, KEY_CHECKED_AT)
if last:
try:
last_dt = datetime.fromisoformat(last)
if datetime.now(timezone.utc) - last_dt < _MIN_CHECK_INTERVAL:
return await load_status()
except ValueError:
pass # corrupted timestamp → fall through and overwrite
async with AsyncSession(get_engine()) as session:
provider_kind = await get_setting(session, KEY_PROVIDER_KIND)
provider_url = await get_setting(session, KEY_PROVIDER_URL)
provider_repo = await get_setting(session, KEY_PROVIDER_REPO)
include_prereleases = (await get_setting(session, KEY_INCLUDE_PRERELEASES)) == "1"
http = await get_http_session()
provider = build_release_provider(
provider_kind or ReleaseProviderKind.DISABLED.value,
session=http,
url=provider_url,
repo=provider_repo,
)
timestamp = datetime.now(timezone.utc).isoformat()
if provider is None:
# Disabled (no error to surface) vs misconfigured (operator action
# required) are different states — the UI distinguishes them.
kind = _coerce_provider_kind(provider_kind)
err = (
ReleaseErrorCode.DISABLED.value
if kind == ReleaseProviderKind.DISABLED.value
else ReleaseErrorCode.MISCONFIGURED.value
)
await persist_release_state(checked_at=timestamp, error=err, info=None)
return await load_status()
try:
info = await provider.fetch_latest(include_prereleases=include_prereleases)
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Release provider network error: %s", err)
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.NETWORK_ERROR.value,
info=None,
)
return await load_status()
except ValueError as err:
_LOGGER.warning("Release provider parse/validation error: %s", err)
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.PARSE_ERROR.value,
info=None,
)
return await load_status()
if info is None:
await persist_release_state(
checked_at=timestamp,
error=ReleaseErrorCode.NO_RELEASE_FOUND.value,
info=None,
)
return await load_status()
await persist_release_state(checked_at=timestamp, error=None, info=info)
return await load_status()
async def persist_release_state(
*,
checked_at: str,
error: str | None,
info: ReleaseInfo | None,
) -> None:
"""Write all cached-state keys in one transaction.
Public because the settings PUT handler invokes it to flush stale cache
when the operator points the provider at a different repo we don't want
the previous repo's "latest" to keep advertising as available.
"""
if info is None:
rows: dict[str, str] = {
KEY_LATEST_TAG: "",
KEY_LATEST_VERSION: "",
KEY_LATEST_URL: "",
KEY_LATEST_BODY: "",
KEY_LATEST_NAME: "",
KEY_LATEST_PUBLISHED_AT: "",
KEY_LATEST_PRERELEASE: "0",
}
else:
rows = {
KEY_LATEST_TAG: info.tag,
KEY_LATEST_VERSION: info.version,
KEY_LATEST_URL: info.url or "",
KEY_LATEST_BODY: info.body or "",
KEY_LATEST_NAME: info.name or "",
KEY_LATEST_PUBLISHED_AT: info.published_at or "",
KEY_LATEST_PRERELEASE: "1" if info.prerelease else "0",
}
rows[KEY_CHECKED_AT] = checked_at
rows[KEY_ERROR] = error or ""
async with AsyncSession(get_engine()) as session:
for key, value in rows.items():
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
await session.commit()
def cached_keys() -> tuple[str, ...]:
"""Return the keys the checker writes — used by API masking helpers."""
return _CACHED_KEYS
@@ -42,8 +42,9 @@ from ..database.models import (
TrackingConfig,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -262,7 +263,11 @@ async def dispatch_scheduled_for_tracker(
if tc is not None:
if not getattr(tc, f"{kind}_enabled", True):
continue
if not event_allowed_by_config(event, tc, app_tz):
# Scheduled / periodic / memory dispatches are wall-clock
# by nature — a "good morning" delivered at 3 pm is wrong,
# so quiet hours = drop (not defer) for these kinds. The
# other gate (per-event-type flag) still applies.
if not evaluate_event_gate(event, tc, app_tz).allowed:
continue
if tmpl is None:
continue
@@ -153,6 +153,16 @@ async def start_scheduler() -> None:
# Load scheduled backup job if enabled
await _load_backup_job()
# Re-arm any deferred-dispatch drains that were pending across restart.
from .deferred_dispatch import load_pending_drain_jobs
await load_pending_drain_jobs()
# And install the periodic safety-net catch-up scan.
_schedule_drain_catchup()
# Schedule the upstream release-check probe.
await _schedule_release_check()
def _schedule_event_cleanup() -> None:
"""Schedule a daily job to delete EventLog entries older than 90 days."""
@@ -1079,6 +1089,129 @@ async def unschedule_backup() -> None:
_LOGGER.info("Unscheduled backup job")
# ---------------------------------------------------------------------------
# Deferred-dispatch drain
# ---------------------------------------------------------------------------
#
# When ``defer_event`` enqueues a quiet-hours notification, the calling site
# asks us to add a one-shot ``date`` job at ``quiet_hours_end_at``. We key the
# job id by the minute-rounded end time so multiple defers that share the same
# window-end share a single drain job (idempotent via ``replace_existing``).
#
# At fire time the job runs ``drain_deferred_due`` which scans all pending
# rows and dispatches whatever is ready.
#
# A periodic catch-up scan runs every ``_DRAIN_CATCHUP_INTERVAL_SECONDS`` as
# the safety net for failure modes the one-shot job can't cover:
# * APScheduler's misfire grace exceeded (event loop blocked past fire_at;
# the date job is silently discarded by the scheduler)
# * Process killed between the deferred-row DB commit and the
# ``schedule_deferred_drain`` call — row exists, job doesn't
# * Clock drift / DST seam edge cases
_DEFERRED_DRAIN_PREFIX = "deferred_drain_"
_DEFERRED_DRAIN_CATCHUP_JOB = "deferred_drain_catchup"
# Generous so a temporarily-blocked event loop doesn't make the scheduler
# discard our drain job. Once discarded the deferred rows would wait for the
# next process restart or the catch-up scan below — survivable but visibly
# late from the user's perspective.
_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS = 3600
# 5 min trade-off between "promptness of late delivery" and "extra DB churn".
# The scan is a single indexed lookup on (status, fire_at).
_DRAIN_CATCHUP_INTERVAL_SECONDS = 300
def _drain_job_id_for(fire_at_utc: datetime) -> str:
return f"{_DEFERRED_DRAIN_PREFIX}{fire_at_utc.strftime('%Y%m%d%H%M')}"
def schedule_deferred_drain(fire_at_utc: datetime) -> None:
"""Add an idempotent one-shot drain job for ``fire_at_utc``.
Past times schedule a near-immediate firing (now+1s) the drain query
handles ``fire_at <= now`` regardless of which job fired, so a near-miss
still picks up the work.
"""
from datetime import datetime, timezone
if fire_at_utc.tzinfo is None:
fire_at_utc = fire_at_utc.replace(tzinfo=timezone.utc)
scheduler = get_scheduler()
job_id = _drain_job_id_for(fire_at_utc)
run_at = fire_at_utc
if run_at <= datetime.now(timezone.utc):
from datetime import timedelta
run_at = datetime.now(timezone.utc) + timedelta(seconds=1)
scheduler.add_job(
_run_deferred_drain,
"date",
run_date=run_at,
id=job_id,
args=[fire_at_utc.isoformat()],
replace_existing=True,
max_instances=1,
# Override the global 5-min grace — see module-level comment.
misfire_grace_time=_DEFERRED_DRAIN_MISFIRE_GRACE_SECONDS,
)
_LOGGER.debug("Scheduled deferred drain %s (fire_at=%s)", job_id, fire_at_utc.isoformat())
def _schedule_drain_catchup() -> None:
"""Install the periodic catch-up scan. See module comment."""
from apscheduler.triggers.interval import IntervalTrigger
scheduler = get_scheduler()
if scheduler.get_job(_DEFERRED_DRAIN_CATCHUP_JOB):
return
scheduler.add_job(
_run_deferred_drain_catchup,
IntervalTrigger(seconds=_DRAIN_CATCHUP_INTERVAL_SECONDS),
id=_DEFERRED_DRAIN_CATCHUP_JOB,
replace_existing=True,
max_instances=1,
coalesce=True,
)
_LOGGER.info(
"Scheduled deferred-dispatch catch-up scan every %ds",
_DRAIN_CATCHUP_INTERVAL_SECONDS,
)
async def _run_deferred_drain(fire_at_iso: str) -> None:
"""APScheduler entry point — log the original fire_at then drain due rows.
The ``fire_at_iso`` arg is only used for logging; the drain itself picks
up every pending row whose ``fire_at`` has passed.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
_LOGGER.info("Deferred drain (fire_at=%s) stats: %s", fire_at_iso, stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred drain (fire_at=%s) failed: %s", fire_at_iso, err)
async def _run_deferred_drain_catchup() -> None:
"""Periodic safety-net drain — see module comment.
Distinct from the per-fire-at job only in cadence and log line; calls the
same ``drain_deferred_due`` which is a no-op when nothing is due.
"""
from .deferred_dispatch import drain_deferred_due
try:
stats = await drain_deferred_due()
# Quiet at debug level when nothing happened — every 5 min is too
# noisy at info on an idle system.
if stats.get("fired") or stats.get("dropped") or stats.get("errors"):
_LOGGER.info("Deferred catch-up stats: %s", stats)
else:
_LOGGER.debug("Deferred catch-up stats: %s", stats)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Deferred catch-up drain failed: %s", err)
async def _run_scheduled_backup() -> None:
"""Run a scheduled backup (called by APScheduler)."""
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
@@ -1116,3 +1249,66 @@ async def _run_scheduled_backup() -> None:
except Exception as e:
_LOGGER.error("Scheduled backup failed: %s", e)
# --- Release-check probe -----------------------------------------------------
_RELEASE_CHECK_JOB_ID = "upstream_release_check"
_RELEASE_CHECK_ONESHOT_JOB_ID = "upstream_release_check_oneshot"
_RELEASE_CHECK_ONESHOT_DELAY_SECONDS = 30
async def _schedule_release_check() -> None:
"""Register the interval + one-shot release-check jobs.
Reads the configured interval from AppSettings at startup. Idempotent
APScheduler de-dupes via ``replace_existing=True``.
"""
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timedelta, timezone
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.app_settings import get_setting
from ..database.engine import get_engine
from .release_check import parse_interval_hours, run_check
async with AsyncSession(get_engine()) as session:
raw = await get_setting(session, "release_check_interval_hours")
interval_hours = parse_interval_hours(raw)
scheduler = get_scheduler()
scheduler.add_job(
run_check,
IntervalTrigger(hours=interval_hours),
id=_RELEASE_CHECK_JOB_ID,
replace_existing=True,
max_instances=1,
)
# One-shot probe shortly after start so admins see a fresh status without
# waiting for the first interval tick. Mirrors the chat-title sync.
scheduler.add_job(
run_check,
"date",
run_date=datetime.now(timezone.utc) + timedelta(seconds=_RELEASE_CHECK_ONESHOT_DELAY_SECONDS),
id=_RELEASE_CHECK_ONESHOT_JOB_ID,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Scheduled release-check every %sh (one-shot in %ss)",
interval_hours, _RELEASE_CHECK_ONESHOT_DELAY_SECONDS)
async def reschedule_release_check() -> None:
"""Re-arm the release-check job after settings changed.
Called from the PUT /settings handler when the interval or provider config
changes. Removes the existing interval job, lets ``_schedule_release_check``
re-read the setting and rebuild it, and queues a fresh one-shot so the new
config takes effect within seconds rather than at the next interval tick.
"""
scheduler = get_scheduler()
if scheduler.get_job(_RELEASE_CHECK_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_JOB_ID)
if scheduler.get_job(_RELEASE_CHECK_ONESHOT_JOB_ID):
scheduler.remove_job(_RELEASE_CHECK_ONESHOT_JOB_ID)
await _schedule_release_check()
@@ -22,8 +22,9 @@ from ..database.models import (
ServiceProvider,
)
from .dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
event_allowed_by_config,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
@@ -205,11 +206,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need
# Snapshot the data we need. These reads happen INSIDE the open
# session so we get fresh attribute values; once the block exits, the
# ORM instances become detached and any unfetched attribute access
# would raise. Pulling primitives here is the deliberate isolation
# boundary between the DB phase and the network phase.
provider_type = provider.type
provider_config = dict(provider.config)
provider_name = provider.name
tracker_name = tracker.name
tracker_user_id = tracker.user_id
tracker_filters = dict(tracker.filters) if tracker.filters else {}
collection_ids = list(tracker.collection_ids or [])
@@ -317,6 +323,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
)
session.add(new_ts)
# Capture the event_log row id alongside each event so the dispatch
# loop below can stamp a "dispatch_status=deferred" pointer onto the
# row if quiet hours suppresses it.
event_log_id_by_event: dict[int, int] = {}
for event in events:
assets_count = event.added_count or event.removed_count or 0
details: dict[str, Any] = {
@@ -352,6 +362,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
details=details,
)
session.add(log)
await session.flush()
event_log_id_by_event[id(event)] = log.id
await session.commit()
@@ -377,21 +389,54 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
asset_cache=asset_cache,
session=shared_session,
)
from .deferred_dispatch import defer_event, is_deferrable
from .scheduler import schedule_deferred_drain
from ..database.models import EventLog as _EventLog
for event in events:
_LOGGER.info(
"Dispatching event %s for %s (added=%d removed=%d)",
event.event_type.value, event.collection_name,
event.added_count, event.removed_count,
)
event_log_id = event_log_id_by_event.get(id(event))
# Group targets by tracking-config identity so each unique TC
# gets one event-transform pass; targets sharing a TC dispatch
# together (preserves the gather-fan-out inside the dispatcher).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
# Track defers in a single dict so we can persist them in one
# session + commit at the end of the iteration. ``load_link_data``
# emits multiple entries per broadcast link (one per child) sharing
# the same parent ``link_id``; the deferred row is one-per-link, so
# ``dict`` keying by ``link_id`` naturally dedupes.
defers_for_event: dict[int, datetime] = {}
scheduled_until: datetime | None = None
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
# Per-link earliest fire_at wins if a future
# iteration ever supplies a different end.
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
_LOGGER.info(
" Deferred until %s (quiet hours)",
outcome.quiet_hours_end_at.isoformat() if outcome.quiet_hours_end_at else "?",
)
else:
_LOGGER.info(
" Suppressed (quiet hours; event type not deferrable)",
)
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
_LOGGER.info(" Skipped by tracking config filter")
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
@@ -410,6 +455,47 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp the event_log row + schedule drains in a
# single transaction. This keeps the "deferred" pill on the
# dashboard consistent with the existence of pending rows even if
# the process is killed mid-way (either both land or neither does).
if defers_for_event:
async with AsyncSession(engine) as defer_session:
for link_id, fire_at in defers_for_event.items():
await defer_event(
defer_session,
event=event,
user_id=tracker_user_id,
tracker_id=tracker_id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
if scheduled_until is None or fire_at < scheduled_until:
scheduled_until = fire_at
# Stamp event_log row inside the SAME session so the
# "deferred until" pill is only visible if the rows
# actually persist.
if event_log_id is not None and scheduled_until is not None:
el = await defer_session.get(_EventLog, event_log_id)
if el is not None:
existing = dict(el.details or {})
if not existing.get("dispatch_status"):
existing["dispatch_status"] = "deferred"
existing["deferred_until"] = scheduled_until.isoformat()
el.details = existing
defer_session.add(el)
await defer_session.commit()
# Drain job registration is best-effort: a failure here just
# delays delivery until the next scan/restart, not data loss.
for fire_at in {*defers_for_event.values()}:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
for tc, target_configs in groups.values():
if not target_configs:
continue
@@ -0,0 +1,83 @@
"""Server version resolution.
Production Docker images install the wheel and ``importlib.metadata`` is the
truth. Editable dev installs (``pip install -e packages/server``) record the
version at install time and *don't auto-refresh* when the source ``pyproject.toml``
bumps so a developer that bumped from 0.3.x to 0.7.x without reinstalling
will keep reporting 0.3.x via ``importlib.metadata``.
To make the running app match the source tree without forcing a reinstall,
we read both and return the higher of the two. The dist-info wins in prod
(no pyproject alongside), the source wins in dev when the editable install is
stale.
"""
from __future__ import annotations
import logging
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
_PACKAGE_NAME = "notify-bridge-server"
_UNKNOWN = "0.0.0+unknown"
def _read_source_version() -> str | None:
"""Best-effort read of the source ``pyproject.toml`` version.
Returns ``None`` when the file isn't reachable (the normal prod case),
so callers fall back to the installed metadata.
"""
# Module is at packages/server/src/notify_bridge_server/version.py,
# pyproject sits at packages/server/pyproject.toml — three parents up.
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.is_file():
return None
try:
import tomllib # Python 3.11+ stdlib — server requires 3.12.
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
version = data.get("project", {}).get("version")
return str(version) if version else None
except (OSError, ValueError) as err: # pragma: no cover — defensive
_LOGGER.debug("Could not read source pyproject version: %s", err)
return None
def _segments(version: str) -> tuple[int, ...]:
"""Best-effort tuple-of-ints for ordering. Suffixes (``-rc1``) are stripped."""
if not version:
return ()
head = version.split("+", 1)[0].split("-", 1)[0]
out: list[int] = []
for piece in head.split("."):
digits = "".join(c for c in piece if c.isdigit())
if digits:
out.append(int(digits))
return tuple(out)
def resolve_version() -> str:
"""Return the version the running server should advertise.
Prefers the highest of (installed metadata, source pyproject) so an
out-of-date editable install never lies to the UI. In production builds
only the installed metadata is available, which is correct by definition.
"""
try:
installed: str | None = _pkg_version(_PACKAGE_NAME)
except PackageNotFoundError:
installed = None
source = _read_source_version()
candidates = [v for v in (installed, source) if v]
if not candidates:
return _UNKNOWN
if len(candidates) == 1:
return candidates[0]
# Two candidates — return the higher by numeric segments. Ties: prefer
# source, since that's what the developer just edited.
a, b = candidates
return a if _segments(a) > _segments(b) else b
@@ -0,0 +1,431 @@
"""Tests for the quiet-hours deferred-dispatch pipeline.
Covers the four behaviours that distinguish the new feature from the legacy
"drop on quiet hours" code path:
1. ``quiet_hours_status`` returns the correct UTC end datetime, including
overnight windows that wrap past midnight.
2. ``evaluate_event_gate`` distinguishes ``QUIET_HOURS`` (deferrable) from
``EVENT_TYPE_DISABLED`` (drop forever).
3. ``serialize_event`` / ``deserialize_event`` round-trip without losing
asset metadata.
4. ``defer_event`` coalesces ``assets_added`` + ``assets_removed`` of the
same IDs for the same link+collection the cancellation case that
motivated the whole feature.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.providers.base import ServiceProviderType
# ---------------------------------------------------------------------------
# Quiet-hours math
# ---------------------------------------------------------------------------
def test_quiet_hours_status_inside_normal_window(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
# Pretend it's 13:00 UTC inside a 12:00-14:00 window.
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("12:00", "14:00", "UTC")
assert end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)
def test_quiet_hours_status_start_equals_end_returns_none() -> None:
"""``00:00-00:00`` is ambiguous (single instant vs always-on); treat as no window.
Code-review feedback: without this guard, the overnight-window branch would
interpret it as "always quiet" and silently defer every notification all
day. The conservative read is that the user misconfigured and we should
behave as if quiet hours were off.
"""
from notify_bridge_server.services import dispatch_helpers as dh
assert dh.quiet_hours_status("00:00", "00:00", "UTC") is None
assert dh.quiet_hours_status("13:30", "13:30", "UTC") is None
def test_quiet_hours_status_outside_window_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 15, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
assert dh.quiet_hours_status("12:00", "14:00", "UTC") is None
def test_quiet_hours_status_overnight_window_post_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
"""22:00-06:00 window, current time 03:00 → window ends today at 06:00."""
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 3, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
assert end_at == datetime(2026, 5, 12, 6, 0, tzinfo=timezone.utc)
def test_quiet_hours_status_overnight_window_pre_midnight(monkeypatch: pytest.MonkeyPatch) -> None:
"""22:00-06:00 window, current time 23:30 → window ends tomorrow at 06:00."""
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 23, 30, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
end_at = dh.quiet_hours_status("22:00", "06:00", "UTC")
assert end_at == datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Gate enum / outcome
# ---------------------------------------------------------------------------
def _make_event(
event_type: EventType = EventType.ASSETS_ADDED,
*,
added_assets: list[MediaAsset] | None = None,
) -> ServiceEvent:
return ServiceEvent(
event_type=event_type,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
added_assets=added_assets or [],
added_count=len(added_assets or []),
)
def _make_asset(asset_id: str, *, filename: str | None = None) -> MediaAsset:
return MediaAsset(
id=asset_id,
type=MediaType.IMAGE,
filename=filename or f"{asset_id}.jpg",
created_at=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
)
class _FakeTrackingConfig:
"""Minimal stand-in for TrackingConfig — only the fields the gate reads."""
def __init__(
self,
*,
quiet_hours_enabled: bool = False,
quiet_hours_start: str | None = None,
quiet_hours_end: str | None = None,
track_assets_added: bool = True,
) -> None:
self.quiet_hours_enabled = quiet_hours_enabled
self.quiet_hours_start = quiet_hours_start
self.quiet_hours_end = quiet_hours_end
self.track_assets_added = track_assets_added
# The gate's flag map reads every track_* attribute; set the rest to
# True so it doesn't accidentally block on an unrelated event type.
for attr in (
"track_assets_removed", "track_collection_renamed",
"track_collection_deleted", "track_sharing_changed",
"track_push", "track_issue_opened", "track_issue_closed",
"track_issue_commented", "track_pr_opened", "track_pr_closed",
"track_pr_merged", "track_pr_commented", "track_release_published",
"track_card_created", "track_card_updated", "track_card_moved",
"track_card_deleted", "track_card_commented", "track_comment_updated",
"track_board_created", "track_board_updated", "track_board_deleted",
"track_list_created", "track_list_updated", "track_list_deleted",
"track_attachment_created", "track_card_label_added",
"track_task_completed", "track_scheduled_message",
"track_webhook_received", "track_ups_online", "track_ups_on_battery",
"track_ups_low_battery", "track_ups_battery_restored",
"track_ups_comms_lost", "track_ups_comms_restored",
"track_ups_replace_battery", "track_ups_overload",
):
setattr(self, attr, True)
def test_gate_quiet_hours_wins_over_event_type_flag(monkeypatch: pytest.MonkeyPatch) -> None:
from notify_bridge_server.services import dispatch_helpers as dh
class _FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 5, 12, 13, 0, tzinfo=timezone.utc)
monkeypatch.setattr(dh, "datetime", _FixedDatetime)
tc = _FakeTrackingConfig(
quiet_hours_enabled=True,
quiet_hours_start="12:00",
quiet_hours_end="14:00",
# Even with the event-type flag flipped off, quiet hours should be
# the reported reason — it's the "louder" gate. The downstream defer
# path treats this as a deferral candidate; flipping the order would
# silently drop deferrable events when both gates are closed.
track_assets_added=False,
)
outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
assert outcome.reason is dh.GateReason.QUIET_HOURS
assert outcome.quiet_hours_end_at == datetime(2026, 5, 12, 14, 0, tzinfo=timezone.utc)
def test_gate_event_type_disabled_when_quiet_hours_off() -> None:
from notify_bridge_server.services import dispatch_helpers as dh
tc = _FakeTrackingConfig(quiet_hours_enabled=False, track_assets_added=False)
outcome = dh.evaluate_event_gate(_make_event(), tc, "UTC")
assert outcome.reason is dh.GateReason.EVENT_TYPE_DISABLED
assert outcome.quiet_hours_end_at is None
# ---------------------------------------------------------------------------
# Event payload round-trip
# ---------------------------------------------------------------------------
def test_serialize_deserialize_roundtrips_assets_and_extras() -> None:
from notify_bridge_server.services import deferred_dispatch as dd
asset = _make_asset("a1")
asset.extra = {"city": "Minsk", "is_favorite": True, "rating": 5}
event = _make_event(added_assets=[asset])
event.extra = {"people": ["Alice"]}
payload = dd.serialize_event(event)
restored = dd.deserialize_event(payload)
assert restored.event_type is EventType.ASSETS_ADDED
assert restored.provider_type is ServiceProviderType.IMMICH
assert restored.collection_id == "col-1"
assert len(restored.added_assets) == 1
assert restored.added_assets[0].id == "a1"
assert restored.added_assets[0].extra["city"] == "Minsk"
assert restored.extra["people"] == ["Alice"]
assert restored.timestamp == event.timestamp
# ---------------------------------------------------------------------------
# Coalescing — the add-then-remove cancellation that motivated the design
# ---------------------------------------------------------------------------
@pytest.fixture
async def empty_session():
"""In-memory SQLite session for coalescing tests — no fixtures, just a clean DB."""
# Importing models here registers them on SQLModel.metadata. We rely on
# ``DeferredDispatch`` being declared so create_all picks it up.
from notify_bridge_server.database import models # noqa: F401 — side effect
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_add_then_remove_same_assets_cancels_pending(empty_session: AsyncSession) -> None:
"""User adds {A, B}, then removes {A, B} — both pending rows should disappear.
Before this feature this scenario would either spam two late notifications
("added" then "removed") or silently drop both. The cancellation path is
the win that justified the coalescing module.
"""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
add_event = _make_event(
EventType.ASSETS_ADDED,
added_assets=[_make_asset("A"), _make_asset("B")],
)
result = await dd.defer_event(
empty_session,
event=add_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
assert result == "inserted"
remove_event = ServiceEvent(
event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
removed_asset_ids=["A", "B"],
removed_count=2,
)
result = await dd.defer_event(
empty_session,
event=remove_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
pending = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
assert pending == [], "add-then-remove of same IDs should leave the queue empty"
@pytest.mark.asyncio
async def test_add_then_partial_remove_keeps_remainder(empty_session: AsyncSession) -> None:
"""User adds {A, B, C}, then removes {B} — pending row should contain {A, C}."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[
_make_asset("A"), _make_asset("B"), _make_asset("C"),
]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
remove_event = ServiceEvent(
event_type=EventType.ASSETS_REMOVED,
provider_type=ServiceProviderType.IMMICH,
provider_name="test-immich",
collection_id="col-1",
collection_name="Album A",
timestamp=datetime(2026, 5, 12, 12, 5, tzinfo=timezone.utc),
removed_asset_ids=["B"],
removed_count=1,
)
await dd.defer_event(
empty_session,
event=remove_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
# Only the assets_added row survives (B subtracted). No assets_removed
# row because B was just added — its removal is a wash.
assert len(rows) == 1
assert rows[0].event_type == "assets_added"
remaining_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
assert remaining_ids == ["A", "C"]
@pytest.mark.asyncio
async def test_add_then_add_unions_assets(empty_session: AsyncSession) -> None:
"""Two consecutive assets_added events should merge into one pending row."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[_make_asset("A")]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=100, fire_at=fire_at,
)
await empty_session.commit()
await dd.defer_event(
empty_session,
event=_make_event(EventType.ASSETS_ADDED, added_assets=[
_make_asset("B"), _make_asset("C"),
]),
user_id=1, tracker_id=1, link_id=1,
event_log_id=101, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
assert len(rows) == 1
merged_ids = sorted(a["id"] for a in rows[0].event_payload["added_assets"])
assert merged_ids == ["A", "B", "C"]
@pytest.mark.asyncio
async def test_non_asset_event_is_not_coalesced(empty_session: AsyncSession) -> None:
"""Two push events for the same repo should both be queued — historical facts."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
fire_at = datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc)
for i in range(2):
push_event = ServiceEvent(
event_type=EventType.PUSH,
provider_type=ServiceProviderType.GITEA,
provider_name="test-gitea",
collection_id="repo-1",
collection_name="my/repo",
timestamp=datetime(2026, 5, 12, 12, i, tzinfo=timezone.utc),
extra={"commit_sha": f"sha{i}"},
)
await dd.defer_event(
empty_session,
event=push_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100 + i, fire_at=fire_at,
)
await empty_session.commit()
rows = (await empty_session.exec(
select(DeferredDispatch).where(DeferredDispatch.status == "pending")
)).all()
# Both rows survive — pushes don't cancel one another.
assert len(rows) == 2
@pytest.mark.asyncio
async def test_scheduled_message_is_non_deferrable(empty_session: AsyncSession) -> None:
"""``scheduled_message`` is wall-clock — defer_event should refuse to enqueue."""
from notify_bridge_server.services import deferred_dispatch as dd
from notify_bridge_server.database.models import DeferredDispatch
sched_event = ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.SCHEDULER,
provider_name="sched",
collection_id="",
collection_name="",
timestamp=datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
)
result = await dd.defer_event(
empty_session,
event=sched_event,
user_id=1, tracker_id=1, link_id=1,
event_log_id=100,
fire_at=datetime(2026, 5, 13, 6, 0, tzinfo=timezone.utc),
)
assert result == "non_deferrable"
await empty_session.commit()
rows = (await empty_session.exec(select(DeferredDispatch))).all()
assert rows == []
@@ -0,0 +1,235 @@
"""Tests for the release provider abstraction and Gitea probe."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from notify_bridge_core.release import build_release_provider, is_valid_repo
from notify_bridge_core.release.base import (
ReleaseErrorCode,
ReleaseProviderKind,
compare_versions,
is_newer,
normalise_version,
)
from notify_bridge_core.release.gitea import GiteaReleaseProvider
# --- pure utilities ---------------------------------------------------------
def test_normalise_version_strips_v_prefix() -> None:
assert normalise_version("v1.2.3") == "1.2.3"
assert normalise_version("V1.2.3") == "1.2.3"
assert normalise_version("1.2.3") == "1.2.3"
assert normalise_version("") == ""
# Only strip ``v`` when followed by a digit — guard against names like
# ``vendor-1`` being mangled into ``endor-1``.
assert normalise_version("vendor-1") == "vendor-1"
@pytest.mark.parametrize(
("a", "b", "expected"),
[
("0.7.3", "0.7.2", 1),
("0.7.2", "0.7.3", -1),
("0.7.2", "0.7.2", 0),
("v0.7.3", "0.7.2", 1),
("1.0.0", "0.9.99", 1),
# Stable beats prerelease at equal numerics (tie-break).
("0.7.2-rc1", "0.7.2", -1),
("0.7.2", "0.7.2-rc1", 1),
# Implicit prerelease form ``1.0a2`` must NOT extract ``2`` as a
# third numeric segment — equal to ``1.0`` stable, then stable wins.
("1.0a2", "1.0", -1),
("", "0.0.0", 0),
],
)
def test_compare_versions(a: str, b: str, expected: int) -> None:
assert compare_versions(a, b) == expected
def test_is_newer_is_strict() -> None:
assert is_newer("0.7.3", "0.7.2") is True
assert is_newer("0.7.2", "0.7.2") is False
# A pre-release of the next minor should still be flagged as newer when
# explicitly fetched with include_prereleases=True at the provider level.
assert is_newer("0.7.3-rc1", "0.7.2") is True
def test_is_valid_repo() -> None:
assert is_valid_repo("alexei.dolgolyov/notify-bridge") is True
assert is_valid_repo("a/b") is True
assert is_valid_repo("a_b/c.d-e") is True
assert is_valid_repo("") is False
assert is_valid_repo("no-slash") is False
# Path-traversal attempts.
assert is_valid_repo("foo/bar/../admin") is False
assert is_valid_repo("foo/bar/baz") is False
assert is_valid_repo("foo/../bar") is False
# Embedded special chars.
assert is_valid_repo("foo@bar/baz") is False
assert is_valid_repo("foo/bar?x=1") is False
# --- registry ---------------------------------------------------------------
def test_registry_returns_none_for_disabled() -> None:
assert build_release_provider("disabled", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_returns_none_for_unknown_kind() -> None:
assert build_release_provider("svn", session=MagicMock(), url="x", repo="a/b") is None
def test_registry_gitea_requires_url_and_valid_repo() -> None:
sess = MagicMock()
assert build_release_provider("gitea", session=sess, url="", repo="a/b") is None
assert build_release_provider("gitea", session=sess, url="https://x", repo="") is None
# Path traversal blocked by repo validation.
assert build_release_provider("gitea", session=sess, url="https://x", repo="a/b/../c") is None
provider = build_release_provider("gitea", session=sess, url="https://x", repo="a/b")
assert isinstance(provider, GiteaReleaseProvider)
assert provider.kind is ReleaseProviderKind.GITEA
# --- Gitea provider ---------------------------------------------------------
def _gitea_payload(**overrides: Any) -> list[dict[str, Any]]:
base = {
"tag_name": "v0.7.3",
"name": "v0.7.3",
"html_url": "https://git.example.com/owner/repo/releases/tag/v0.7.3",
"body": "Notes",
"published_at": "2026-05-01T00:00:00Z",
"draft": False,
"prerelease": False,
}
base.update(overrides)
return [base]
class _FakeContent:
def __init__(self, raw: bytes) -> None:
self._raw = raw
async def read(self, n: int = -1) -> bytes:
return self._raw if n < 0 else self._raw[:n]
class _FakeResponse:
def __init__(self, status: int, payload: Any) -> None:
self.status = status
import json
self.content = _FakeContent(json.dumps(payload).encode("utf-8"))
self._payload = payload
async def json(self) -> Any:
return self._payload
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def _session_with(payload: Any, status: int = 200) -> MagicMock:
"""Return a session whose `.get()` yields a fresh response per call.
Using ``side_effect`` rather than ``return_value`` ensures multiple
awaited fetches don't share mutable response state across tests.
"""
sess = MagicMock()
sess.get = MagicMock(side_effect=lambda *a, **kw: _FakeResponse(status, payload))
return sess
@pytest.fixture(autouse=True)
def _allow_private_urls(monkeypatch: pytest.MonkeyPatch) -> None:
"""SSRF guard rejects example.com → publicly resolvable, so tests pass.
But we explicitly enable the bypass to remove DNS-resolution flakiness
from CI runs.
"""
monkeypatch.setenv("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS", "1")
# Reload the ssrf module to pick up the env var (it's read at import).
import importlib
import notify_bridge_core.notifications.ssrf as ssrf_mod
importlib.reload(ssrf_mod)
async def test_gitea_fetch_latest_happy_path() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://git.example.com/", "owner/repo")
info = await provider.fetch_latest(include_prereleases=False)
assert info is not None
assert info.tag == "v0.7.3"
assert info.version == "0.7.3"
assert info.url == "https://git.example.com/owner/repo/releases/tag/v0.7.3"
assert info.prerelease is False
async def test_gitea_skips_prereleases_by_default() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=False) is None
async def test_gitea_includes_prereleases_when_asked() -> None:
payload = _gitea_payload(prerelease=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
info = await provider.fetch_latest(include_prereleases=True)
assert info is not None
assert info.prerelease is True
async def test_gitea_skips_drafts() -> None:
payload = _gitea_payload(draft=True)
sess = _session_with(payload)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest(include_prereleases=True) is None
async def test_gitea_returns_none_on_http_error() -> None:
sess = _session_with([], status=500)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
assert await provider.fetch_latest() is None
async def test_gitea_test_returns_structured_status() -> None:
sess = _session_with(_gitea_payload())
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is True
assert result["info"] is not None
assert result["error"] is None
async def test_gitea_test_reports_http_error() -> None:
sess = _session_with([], status=404)
provider = GiteaReleaseProvider(sess, "https://x.example.com", "a/b")
result = await provider.test()
assert result["ok"] is False
assert result["info"] is None
# Taxonomy code, not a raw exception string.
assert result["error"] in {code.value for code in ReleaseErrorCode}
def test_gitea_constructor_validates_repo_format() -> None:
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "no-slash")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "https://x.example.com", "foo/bar/../baz")
with pytest.raises(ValueError):
GiteaReleaseProvider(MagicMock(), "", "owner/repo")
@@ -0,0 +1,144 @@
"""Tests for the release_check service (interval clamping + status endpoints + persistence)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
def test_parse_interval_hours_clamps_and_defaults() -> None:
from notify_bridge_server.services.release_check import parse_interval_hours
assert parse_interval_hours("12") == 12
assert parse_interval_hours("") == 12 # default
assert parse_interval_hours(None) == 12
assert parse_interval_hours("0") == 1 # clamped to min
assert parse_interval_hours("9999") == 168 # clamped to max
assert parse_interval_hours("not-a-number") == 12 # fallback to default
assert parse_interval_hours("24") == 24
def test_release_endpoint_anonymous_is_rejected(tmp_data_dir) -> None: # noqa: ARG001
"""GET /api/settings/release requires auth — same as other settings."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get("/api/settings/release")
# Either 401 (missing token) or 403 (not authenticated) is acceptable.
assert resp.status_code in (401, 403)
def test_release_force_check_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post("/api/settings/release/check")
assert resp.status_code in (401, 403)
def test_release_test_requires_admin(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.post(
"/api/settings/release/test",
json={"provider_kind": "gitea", "provider_url": "https://x.example.com", "provider_repo": "a/b"},
)
assert resp.status_code in (401, 403)
# --- Persistence round-trip -------------------------------------------------
@pytest.mark.asyncio
async def test_persist_release_state_round_trip(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""Write a fake ReleaseInfo, read it back via load_status, assert flags."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
info = ReleaseInfo(
tag="v0.9.0",
version="0.9.0",
name="0.9.0 — Aurora",
body="Release notes",
url="https://example.com/x/y/releases/tag/v0.9.0",
published_at="2026-06-01T00:00:00Z",
prerelease=False,
draft=False,
)
await persist_release_state(
checked_at="2026-06-01T00:01:00+00:00",
error=None,
info=info,
)
# Force the comparator to see an older "current" so update_available
# comes out True regardless of the actual installed package version.
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest == "0.9.0"
assert status.latest_tag == "v0.9.0"
assert status.update_available is True
assert status.error is None
assert status.latest_body == "Release notes"
@pytest.mark.asyncio
async def test_persist_release_state_clears_on_none_info(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A persist call with ``info=None`` must blank all the latest-* fields."""
from notify_bridge_core.release import ReleaseInfo
from notify_bridge_server.database.engine import init_db
from notify_bridge_server.services.release_check import (
load_status,
persist_release_state,
)
await init_db()
# Seed a populated row.
await persist_release_state(
checked_at="2026-06-01T00:00:00+00:00",
error=None,
info=ReleaseInfo(tag="v9.9.9", version="9.9.9"),
)
# Now wipe by passing info=None — mimics the "provider_changed" flow.
await persist_release_state(
checked_at="2026-06-01T00:02:00+00:00",
error="provider_changed",
info=None,
)
monkeypatch.setattr(
"notify_bridge_server.services.release_check._server_version",
lambda: "0.7.0",
)
status = await load_status()
assert status.latest is None
assert status.latest_tag is None
assert status.update_available is False
assert status.error == "provider_changed"
# --- Version resolver -------------------------------------------------------
def test_resolve_version_prefers_source_pyproject() -> None:
"""When pyproject.toml is alongside the source, prefer the higher of (installed, source)."""
from notify_bridge_server.version import resolve_version
v = resolve_version()
assert v != "0.0.0+unknown"
# If the editable install is stale (e.g. 0.3.2) but pyproject says 0.7.2,
# resolve_version must return 0.7.2 (or higher) — the resolver's
# whole purpose. We test the "not stale" half of the contract here.
parts = v.split(".")
assert len(parts) >= 2
assert parts[0].isdigit()