Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 757271dadf | |||
| 73b046f7a2 | |||
| b170c2b792 | |||
| 35a3008896 |
+24
-22
@@ -1,40 +1,42 @@
|
||||
# v0.7.0 (2026-05-07)
|
||||
# v0.7.1 (2026-05-07)
|
||||
|
||||
Hardened notification stack with shared HTTP base, SSRF protections, secret redaction, and a bounded queue across every provider client; Settings logging selectors switched to icon grids; entity names autogenerate from the chosen type or provider across bots, targets, trackers, actions, and configs.
|
||||
## Features
|
||||
|
||||
## User-facing changes
|
||||
- Bot command invocations now appear in the dashboard event stream with `command_handled`, `command_rate_limited`, and `command_failed` rows — closing the last user-initiated path that was invisible to the dashboard ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Click any event row to open a detail modal with full provenance (bot → chat → issuer → provider), raw `details` JSON, and per-entity action buttons that deep-link into the relevant list page with the card scrolled into view and pulsing ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Configurable event auto-refresh dropdown (Off / 10s / 30s / 1m / 5m), persisted in `localStorage`; ticker pauses while the tab is hidden ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Smoother event refresh — no more loading-placeholder flicker on auto-refresh; unchanged rows reuse their DOM nodes and identical pages skip re-rendering entirely ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Page header breadcrumbs are now translated (new `crumbs.*` i18n namespace covering all 15 call sites), so `Routing · Notification`, `Operators · Bots`, etc. switch with the active language ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Tracker form's Immich feature-discovery banner now offers an `Open Template Config` shortcut alongside `Open Tracking Config`, and `/template-configs?edit=<id>` auto-opens the editor on landing ([b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b))
|
||||
- Event-type filter, dashboard verb labels, and gradients extended for the three new `command_*` types; filled in previously missing i18n keys (`common.hide`, `common.show`, `commandConfig.noCommandsForProvider`) ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- Telegram issuer info (`from`) captured in both poller and webhook paths and persisted under `details.issuer`, whitelisted to identity fields only by `_normalize_issuer` so `language_code` and any future PII fields are dropped ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
### Features
|
||||
## Bug Fixes
|
||||
|
||||
- Settings: replace log level and log format dropdowns with icon-grid selectors carrying per-option icons and i18n descriptions ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
|
||||
- Forms: auto-generate entity names from the selected type/provider across bots, targets, notification trackers, command trackers, actions, and tracking/template/command/command-template configs — names update live until you manually edit, then your edit is preserved ([5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2))
|
||||
- Cyrillic glyphs in sidebar nav links, section labels, and monospace badges now render in Geist instead of falling back to Segoe UI / Cascadia / Consolas. Switched to `@fontsource-variable/geist` (latin + latin-ext + cyrillic) and added `@fontsource/geist-mono` cyrillic subsets for weights 400/500/600 ([73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f))
|
||||
|
||||
### Reliability & Security
|
||||
|
||||
- Notification stack hardening: shared HTTP base, SSRF protections, secret redaction in error logs, and a bounded delivery queue across the dispatcher, receiver, and all provider clients (telegram, discord, email, matrix, ntfy, slack, webhook) ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
|
||||
---
|
||||
|
||||
## Development / Internal
|
||||
|
||||
### Refactoring
|
||||
### Database
|
||||
|
||||
- Notifications: extract shared `http_base`, `redact`, and SSRF helpers; refactor dispatcher, queue, receiver, and every provider client onto the new base ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
|
||||
- `EventLog` gains nullable `command_tracker_id` / `telegram_bot_id` FKs plus deletion-snapshot name columns; idempotent migration ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
- `/api/status` resolves live `CommandTracker` / `TelegramBot` names (mirroring the action pattern) and exposes `tracker_id`, `command_tracker_id`, `telegram_bot_id` so the frontend can deep-link ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
### Tests
|
||||
|
||||
- New coverage: SSRF hardening, secret redaction, HTTP base, bounded queue, dispatcher aggregation, Telegram media partitioning, email and matrix clients ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
|
||||
- New `test_command_event_logging.py` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable; full server suite passing 96/96 ([35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008))
|
||||
|
||||
### Tooling
|
||||
|
||||
- Document code-review-graph MCP usage in CLAUDE.md, register `.mcp.json`, and gitignore `.code-review-graph/` ([0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a))
|
||||
|
||||
## All Commits
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
|
||||
| [0eb899a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0eb899a) | feat: harden notification stack and switch logging selectors to icon grid | alexei.dolgolyov |
|
||||
| [5bd63a2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5bd63a2) | feat(frontend): autogenerate entity names from type/provider | alexei.dolgolyov |
|
||||
| Hash | Message | Author |
|
||||
| ---- | ------- | ------ |
|
||||
| [73b046f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/73b046f) | fix(frontend): cyrillic glyphs for nav and section labels | alexei.dolgolyov |
|
||||
| [b170c2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b170c2b) | feat(frontend): smoother event refresh, localized crumbs, template config deep-link | alexei.dolgolyov |
|
||||
| [35a3008](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/35a3008) | feat: log bot command invocations to the event stream | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
Generated
+16
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "notify-bridge-frontend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
@@ -607,6 +608,14 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
@@ -2887,6 +2896,11 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"dev": true
|
||||
},
|
||||
"@fontsource-variable/geist": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
|
||||
"integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="
|
||||
},
|
||||
"@fontsource/dm-sans": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
|
||||
+12
-6
@@ -1,11 +1,17 @@
|
||||
@import '@fontsource/geist-sans/300.css';
|
||||
@import '@fontsource/geist-sans/400.css';
|
||||
@import '@fontsource/geist-sans/500.css';
|
||||
@import '@fontsource/geist-sans/600.css';
|
||||
@import '@fontsource/geist-sans/700.css';
|
||||
/* Sans: variable Geist ships latin + latin-ext + cyrillic in one woff2,
|
||||
so RU and EN render in the same font instead of falling back to a
|
||||
system sans for Cyrillic. Replaces the legacy ``@fontsource/geist-sans``
|
||||
(latin-only) imports — see --font-sans below for the family rename. */
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource/geist-mono/400.css';
|
||||
@import '@fontsource/geist-mono/500.css';
|
||||
@import '@fontsource/geist-mono/600.css';
|
||||
/* Geist Mono cyrillic subsets — same family name, additional unicode-range
|
||||
declarations so Russian text renders in Geist Mono instead of falling
|
||||
back to Cascadia/Consolas. */
|
||||
@import '@fontsource/geist-mono/cyrillic-400.css';
|
||||
@import '@fontsource/geist-mono/cyrillic-500.css';
|
||||
@import '@fontsource/geist-mono/cyrillic-600.css';
|
||||
@import '@fontsource/newsreader/300-italic.css';
|
||||
@import '@fontsource/newsreader/400.css';
|
||||
@import '@fontsource/newsreader/400-italic.css';
|
||||
@@ -68,7 +74,7 @@
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,0.07) inset, 0 30px 60px -20px rgba(0,0,0,0.6);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-sans: 'Geist Variable', 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||
--font-display: 'Newsreader', ui-serif, Georgia, serif;
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { EventLog } from '$lib/types';
|
||||
import { requestHighlight } from '$lib/highlight';
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
event: EventLog | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { event, onclose }: Props = $props();
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function issuerLabel(issuer: { id?: number; username?: string; first_name?: string; last_name?: string } | undefined): string {
|
||||
if (!issuer) return '';
|
||||
if (issuer.username) return '@' + issuer.username;
|
||||
const name = [issuer.first_name, issuer.last_name].filter(Boolean).join(' ');
|
||||
if (name) return name;
|
||||
if (issuer.id) return 'id ' + issuer.id;
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Navigate to a list page and highlight the specific entity card.
|
||||
*
|
||||
* The destination page calls ``highlightFromUrl()`` after data loads,
|
||||
* which scrolls to and pulses the card with ``data-entity-id={id}``.
|
||||
* Same mechanism CrossLink uses elsewhere — keeps the UX consistent. */
|
||||
function openEntity(path: string, entityId: number | string | null | undefined) {
|
||||
if (entityId != null) requestHighlight(entityId);
|
||||
onclose();
|
||||
goto(path);
|
||||
}
|
||||
|
||||
const issuer = $derived(event?.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 detailsJson = $derived.by(() => {
|
||||
if (!event?.details) return '';
|
||||
try {
|
||||
return JSON.stringify(event.details, null, 2);
|
||||
} catch {
|
||||
return String(event.details);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal open={event !== null} title={event ? t('events.detailTitle') : ''} {onclose}>
|
||||
{#if event}
|
||||
<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-meta">
|
||||
<span class="event-type">{event.event_type}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{fmtDateTime(event.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provenance grid -->
|
||||
<dl class="provenance">
|
||||
{#if event.bot_name}
|
||||
<dt>{t('events.bot')}</dt>
|
||||
<dd>{event.bot_name}</dd>
|
||||
{/if}
|
||||
{#if event.collection_id && isCommand}
|
||||
<dt>{t('events.chat')}</dt>
|
||||
<dd class="font-mono">{event.collection_id}</dd>
|
||||
{/if}
|
||||
{#if issuerText}
|
||||
<dt>{t('events.issuer')}</dt>
|
||||
<dd>
|
||||
{issuerText}
|
||||
{#if issuer?.id}<span class="muted font-mono">(id {issuer.id})</span>{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if event.command_tracker_name}
|
||||
<dt>{t('events.commandTracker')}</dt>
|
||||
<dd>{event.command_tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.tracker_name}
|
||||
<dt>{t('events.tracker')}</dt>
|
||||
<dd>{event.tracker_name}</dd>
|
||||
{/if}
|
||||
{#if event.action_name}
|
||||
<dt>{t('events.action')}</dt>
|
||||
<dd>{event.action_name}</dd>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
<dt>{t('events.provider')}</dt>
|
||||
<dd>{event.provider_name}</dd>
|
||||
{/if}
|
||||
{#if event.assets_count > 0}
|
||||
<dt>{t('events.assetsCount')}</dt>
|
||||
<dd class="font-mono">{event.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)}>
|
||||
<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)}>
|
||||
<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)}>
|
||||
<MdiIcon name="mdiChat" size={14} />
|
||||
{t('events.openCommandTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if event.action_id && isAction}
|
||||
<button type="button" onclick={() => openEntity('/actions', event.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)}>
|
||||
<MdiIcon name="mdiRadar" size={14} />
|
||||
{t('events.openTracker')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Raw details JSON (always rendered — frequently the most useful piece) -->
|
||||
{#if detailsJson && detailsJson !== '{}'}
|
||||
<details class="raw-details" open={isCommand}>
|
||||
<summary>{t('events.rawDetails')}</summary>
|
||||
<pre>{detailsJson}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.event-detail {
|
||||
display: flex; flex-direction: column; gap: 1.1rem;
|
||||
}
|
||||
.hero-row {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
}
|
||||
.hero-subject {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.event-type {
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.dot { opacity: 0.5; }
|
||||
|
||||
.provenance {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.45rem 1rem;
|
||||
margin: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in oklab, var(--color-foreground) 4%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.provenance dt {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: center;
|
||||
}
|
||||
.provenance dd {
|
||||
margin: 0;
|
||||
color: var(--color-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
.muted { color: var(--color-muted-foreground); margin-left: 0.35rem; font-size: 0.75rem; }
|
||||
|
||||
.actions {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||
}
|
||||
.actions button {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
.actions button:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.raw-details summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.raw-details summary:hover { color: var(--color-foreground); }
|
||||
.raw-details pre {
|
||||
margin: 0.55rem 0 0;
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-foreground);
|
||||
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -108,6 +108,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
||||
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||
{ value: 'command_handled', icon: 'mdiChat', label: t('dashboard.filterCommandHandled'), desc: t('gridDesc.commandHandled') },
|
||||
{ value: 'command_rate_limited', icon: 'mdiTimerSandPaused', label: t('dashboard.filterCommandRateLimited'), desc: t('gridDesc.commandRateLimited') },
|
||||
{ value: 'command_failed', icon: 'mdiAlertCircle', label: t('dashboard.filterCommandFailed'), desc: t('gridDesc.commandFailed') },
|
||||
];
|
||||
|
||||
// --- Sort filter (dashboard) ---
|
||||
@@ -117,6 +120,19 @@ export const sortFilterItems = (): GridItem[] => [
|
||||
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') },
|
||||
];
|
||||
|
||||
// --- Auto-refresh interval (dashboard events list) ---
|
||||
//
|
||||
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
|
||||
// in routes/+page.svelte if you add or remove cadences.
|
||||
|
||||
export const refreshIntervalItems = (): GridItem[] => [
|
||||
{ value: 0, icon: 'mdiPause', label: t('dashboard.refreshOff'), desc: t('gridDesc.refreshOff') },
|
||||
{ value: 10, icon: 'mdiTimerSand', label: t('dashboard.refresh10s'), desc: t('gridDesc.refresh10s') },
|
||||
{ value: 30, icon: 'mdiTimerOutline', label: t('dashboard.refresh30s'), desc: t('gridDesc.refresh30s') },
|
||||
{ value: 60, icon: 'mdiTimer', label: t('dashboard.refresh60s'), desc: t('gridDesc.refresh60s') },
|
||||
{ value: 300, icon: 'mdiClockOutline', label: t('dashboard.refresh5m'), desc: t('gridDesc.refresh5m') },
|
||||
];
|
||||
|
||||
// --- Chat action (Telegram targets) ---
|
||||
|
||||
export const chatActionItems = (): GridItem[] => [
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Service notifications"
|
||||
},
|
||||
"crumbs": {
|
||||
"routingNotification": "Routing · Notification",
|
||||
"routingCommands": "Routing · Commands",
|
||||
"routingTargets": "Routing · Targets",
|
||||
"routingAutomation": "Routing · Automation",
|
||||
"operatorsBots": "Operators · Bots",
|
||||
"systemAccess": "System · Access",
|
||||
"systemConfiguration": "System · Configuration",
|
||||
"systemMaintenance": "System · Maintenance",
|
||||
"serviceConnections": "Service · Connections"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Overview",
|
||||
"sectionRouting": "Routing",
|
||||
@@ -87,6 +98,15 @@
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
"commandHandled": "command handled",
|
||||
"commandRateLimited": "rate limited",
|
||||
"commandFailed": "command failed",
|
||||
"autoRefreshTitle": "Auto-refresh interval for the events list",
|
||||
"refreshOff": "Off",
|
||||
"refresh10s": "10s",
|
||||
"refresh30s": "30s",
|
||||
"refresh60s": "1m",
|
||||
"refresh5m": "5m",
|
||||
"searchEvents": "Search events...",
|
||||
"allEvents": "All Events",
|
||||
"filterAssetsAdded": "Assets Added",
|
||||
@@ -97,6 +117,9 @@
|
||||
"filterActionSuccess": "Action Success",
|
||||
"filterActionPartial": "Action Partial",
|
||||
"filterActionFailed": "Action Failed",
|
||||
"filterCommandHandled": "Command Handled",
|
||||
"filterCommandRateLimited": "Rate Limited",
|
||||
"filterCommandFailed": "Command Failed",
|
||||
"allProviders": "All Providers",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
@@ -141,6 +164,23 @@
|
||||
"newTracker": "New tracker",
|
||||
"eventsTotal": "Events"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Event details",
|
||||
"bot": "Bot",
|
||||
"chat": "Chat",
|
||||
"issuer": "Issued by",
|
||||
"commandTracker": "Command tracker",
|
||||
"tracker": "Tracker",
|
||||
"action": "Action",
|
||||
"provider": "Provider",
|
||||
"assetsCount": "Assets",
|
||||
"openProvider": "Open provider",
|
||||
"openBot": "Open bot",
|
||||
"openCommandTracker": "Open command tracker",
|
||||
"openAction": "Open action",
|
||||
"openTracker": "Open tracker",
|
||||
"rawDetails": "Raw details"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Service",
|
||||
"titleEmphasis": "providers",
|
||||
@@ -313,6 +353,7 @@
|
||||
"checkingLinks": "Checking links...",
|
||||
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
|
||||
"openTrackingConfig": "Open Tracking Config",
|
||||
"openTemplateConfig": "Open Template Config",
|
||||
"linkReplace": "Replace",
|
||||
"linkReplacing": "Replacing...",
|
||||
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
|
||||
@@ -889,6 +930,7 @@
|
||||
"titleEmphasis": "configs",
|
||||
"countLabel": "configs",
|
||||
"title": "Command Configs",
|
||||
"noCommandsForProvider": "No commands available for this provider type.",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
@@ -1025,6 +1067,8 @@
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
@@ -1149,6 +1193,14 @@
|
||||
"actionSuccess": "Scheduled action completed",
|
||||
"actionPartial": "Scheduled action partially succeeded",
|
||||
"actionFailed": "Scheduled action failed",
|
||||
"commandHandled": "Bot command served",
|
||||
"commandRateLimited": "Bot command throttled",
|
||||
"commandFailed": "Bot command crashed",
|
||||
"refreshOff": "Auto-refresh disabled",
|
||||
"refresh10s": "Refresh every 10 seconds",
|
||||
"refresh30s": "Refresh every 30 seconds",
|
||||
"refresh60s": "Refresh every minute",
|
||||
"refresh5m": "Refresh every 5 minutes",
|
||||
"newestFirst": "Most recent events on top",
|
||||
"oldestFirst": "Oldest events on top",
|
||||
"chatActionNone": "No indicator shown",
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
"name": "Notify Bridge",
|
||||
"tagline": "Уведомления о сервисах"
|
||||
},
|
||||
"crumbs": {
|
||||
"routingNotification": "Маршрутизация · Уведомления",
|
||||
"routingCommands": "Маршрутизация · Команды",
|
||||
"routingTargets": "Маршрутизация · Цели",
|
||||
"routingAutomation": "Маршрутизация · Автоматизация",
|
||||
"operatorsBots": "Операторы · Боты",
|
||||
"systemAccess": "Система · Доступ",
|
||||
"systemConfiguration": "Система · Настройки",
|
||||
"systemMaintenance": "Система · Обслуживание",
|
||||
"serviceConnections": "Сервис · Подключения"
|
||||
},
|
||||
"nav": {
|
||||
"sectionOverview": "Обзор",
|
||||
"sectionRouting": "Маршрутизация",
|
||||
@@ -87,6 +98,15 @@
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"commandHandled": "команда обработана",
|
||||
"commandRateLimited": "ограничение частоты",
|
||||
"commandFailed": "команда упала",
|
||||
"autoRefreshTitle": "Интервал авто-обновления списка событий",
|
||||
"refreshOff": "Выкл",
|
||||
"refresh10s": "10с",
|
||||
"refresh30s": "30с",
|
||||
"refresh60s": "1м",
|
||||
"refresh5m": "5м",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -97,6 +117,9 @@
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"filterCommandHandled": "Команда обработана",
|
||||
"filterCommandRateLimited": "Ограничение частоты",
|
||||
"filterCommandFailed": "Команда упала",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -141,6 +164,23 @@
|
||||
"newTracker": "Новый трекер",
|
||||
"eventsTotal": "Событий"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Детали события",
|
||||
"bot": "Бот",
|
||||
"chat": "Чат",
|
||||
"issuer": "Отправитель",
|
||||
"commandTracker": "Командный трекер",
|
||||
"tracker": "Трекер",
|
||||
"action": "Действие",
|
||||
"provider": "Провайдер",
|
||||
"assetsCount": "Файлов",
|
||||
"openProvider": "Открыть провайдера",
|
||||
"openBot": "Открыть бота",
|
||||
"openCommandTracker": "Открыть командный трекер",
|
||||
"openAction": "Открыть действие",
|
||||
"openTracker": "Открыть трекер",
|
||||
"rawDetails": "Сырые данные"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
@@ -313,6 +353,7 @@
|
||||
"checkingLinks": "Проверка ссылок...",
|
||||
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
||||
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
||||
"openTemplateConfig": "Открыть конфигурацию шаблона",
|
||||
"linkReplace": "Пересоздать",
|
||||
"linkReplacing": "Пересоздание...",
|
||||
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
||||
@@ -889,6 +930,7 @@
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -1025,6 +1067,8 @@
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"hide": "Скрыть",
|
||||
"show": "Показать",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
@@ -1149,6 +1193,14 @@
|
||||
"actionSuccess": "Запланированное действие выполнено",
|
||||
"actionPartial": "Запланированное действие выполнено частично",
|
||||
"actionFailed": "Запланированное действие провалено",
|
||||
"commandHandled": "Команда бота обработана",
|
||||
"commandRateLimited": "Команда бота ограничена по частоте",
|
||||
"commandFailed": "Команда бота вызвала ошибку",
|
||||
"refreshOff": "Автообновление выключено",
|
||||
"refresh10s": "Обновлять каждые 10 секунд",
|
||||
"refresh30s": "Обновлять каждые 30 секунд",
|
||||
"refresh60s": "Обновлять каждую минуту",
|
||||
"refresh5m": "Обновлять каждые 5 минут",
|
||||
"newestFirst": "Сначала новые события",
|
||||
"oldestFirst": "Сначала старые события",
|
||||
"chatActionNone": "Индикатор не показывается",
|
||||
|
||||
@@ -217,9 +217,16 @@ export interface EventLog {
|
||||
event_type: string;
|
||||
collection_id: string;
|
||||
collection_name: string;
|
||||
tracker_id?: number | null;
|
||||
tracker_name: string;
|
||||
provider_name: string;
|
||||
provider_id: number | null;
|
||||
action_id?: number | null;
|
||||
action_name?: string;
|
||||
command_tracker_id?: number | null;
|
||||
command_tracker_name?: string;
|
||||
telegram_bot_id?: number | null;
|
||||
bot_name?: string;
|
||||
assets_count: number;
|
||||
details: Record<string, any>;
|
||||
created_at: string;
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import EventChart from '$lib/components/EventChart.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import EventDetailModal from '$lib/components/EventDetailModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
import type { DashboardStatus } from '$lib/types';
|
||||
import type { DashboardStatus, EventLog } from '$lib/types';
|
||||
|
||||
const SECTIONS_KEY = 'dashboard_section_state';
|
||||
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
|
||||
@@ -75,10 +76,53 @@
|
||||
return stored ? parseInt(stored, 10) || 10 : 10;
|
||||
}
|
||||
|
||||
// Auto-refresh: 0 = off, otherwise seconds between refreshes.
|
||||
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep
|
||||
// this whitelist in sync with that helper so a stale localStorage
|
||||
// value can't smuggle in an unsupported interval (e.g. someone
|
||||
// hand-edits to 1).
|
||||
const EVENTS_REFRESH_KEY = 'dashboard_events_refresh_seconds';
|
||||
const ALLOWED_REFRESH_SECONDS = new Set([0, 10, 30, 60, 300]);
|
||||
function loadRefreshSeconds(): number {
|
||||
if (typeof localStorage === 'undefined') return 0;
|
||||
const stored = localStorage.getItem(EVENTS_REFRESH_KEY);
|
||||
const v = stored ? parseInt(stored, 10) : 0;
|
||||
return ALLOWED_REFRESH_SECONDS.has(v) ? v : 0;
|
||||
}
|
||||
|
||||
let eventsLimit = $state(loadEventsPerPage());
|
||||
let eventsOffset = $state(0);
|
||||
let eventsLoading = $state(false);
|
||||
let confirmClearEvents = $state(false);
|
||||
let refreshSeconds = $state(loadRefreshSeconds());
|
||||
let selectedEvent = $state<EventLog | null>(null);
|
||||
|
||||
// Auto-refresh ticker — re-creates the interval whenever the user
|
||||
// changes the cadence. ``$effect`` returns a cleanup that fires on
|
||||
// destroy AND on any tracked dep change, so the prior timer is torn
|
||||
// down before a new one starts.
|
||||
$effect(() => {
|
||||
if (refreshSeconds <= 0) return;
|
||||
// Pause auto-refresh when the tab is hidden so we don't burn API
|
||||
// calls on a tab the user can't see — we'll catch up on the next
|
||||
// visibility flip via ``visibilitychange`` below.
|
||||
const tick = () => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
loadEvents({ silent: true });
|
||||
loadChart();
|
||||
};
|
||||
const handle = setInterval(tick, refreshSeconds * 1000);
|
||||
return () => clearInterval(handle);
|
||||
});
|
||||
|
||||
// Persist whenever the cadence changes (the IconGridSelect mutates
|
||||
// ``refreshSeconds`` directly via bind:value).
|
||||
let _refreshHydrated = false;
|
||||
$effect(() => {
|
||||
const v = refreshSeconds;
|
||||
if (!_refreshHydrated) { _refreshHydrated = true; return; }
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v));
|
||||
});
|
||||
|
||||
async function clearEvents() {
|
||||
try {
|
||||
@@ -119,22 +163,53 @@
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
eventsLoading = true;
|
||||
/** Reload the events panel.
|
||||
*
|
||||
* ``silent`` is set by the auto-refresh ticker so the loading
|
||||
* placeholder doesn't flash and the row list isn't disturbed when
|
||||
* nothing actually changed. We diff the new payload against the
|
||||
* current ``status`` and reuse the existing ``recent_events`` array
|
||||
* reference when the ID list is identical — that lets Svelte's keyed
|
||||
* ``{#each}`` skip its diff entirely instead of patching every row.
|
||||
*/
|
||||
async function loadEvents(opts: { silent?: boolean } = {}) {
|
||||
if (!opts.silent) eventsLoading = true;
|
||||
try {
|
||||
const params = buildFilterParams();
|
||||
params.set('sort', filterSort);
|
||||
params.set('limit', String(eventsLimit));
|
||||
params.set('offset', String(eventsOffset));
|
||||
const qs = params.toString();
|
||||
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
||||
const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
||||
|
||||
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
|
||||
// Nothing changed in the visible page. Update only the
|
||||
// out-of-band counts so the header and pager stay accurate;
|
||||
// keep the existing array reference so no row re-renders.
|
||||
status = {
|
||||
...status,
|
||||
providers: next.providers,
|
||||
trackers: next.trackers,
|
||||
targets: next.targets,
|
||||
total_events: next.total_events,
|
||||
command_trackers: next.command_trackers,
|
||||
};
|
||||
return;
|
||||
}
|
||||
status = next;
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : t('common.error');
|
||||
} finally {
|
||||
eventsLoading = false;
|
||||
if (!opts.silent) eventsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
try {
|
||||
const params = buildFilterParams();
|
||||
@@ -360,6 +435,9 @@
|
||||
action_success: 'dashboard.actionSuccess',
|
||||
action_partial: 'dashboard.actionPartial',
|
||||
action_failed: 'dashboard.actionFailed',
|
||||
command_handled: 'dashboard.commandHandled',
|
||||
command_rate_limited: 'dashboard.commandRateLimited',
|
||||
command_failed: 'dashboard.commandFailed',
|
||||
};
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
@@ -367,6 +445,7 @@
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
scheduled_message: 'mdiCalendarClock',
|
||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||
command_handled: 'mdiChat', command_rate_limited: 'mdiTimerSandPaused', command_failed: 'mdiAlertCircle',
|
||||
};
|
||||
|
||||
// Aurora gradient palette per event type — used for the avatar tile
|
||||
@@ -380,6 +459,9 @@
|
||||
action_success: ['var(--color-mint)', 'var(--color-primary)'],
|
||||
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
|
||||
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||
command_handled: ['var(--color-sky)', 'var(--color-primary)'],
|
||||
command_rate_limited:['var(--color-citrus)', 'var(--color-orchid)'],
|
||||
command_failed: ['var(--color-coral)', 'var(--color-orchid)'],
|
||||
};
|
||||
|
||||
const STAT_ACCENTS = [
|
||||
@@ -554,6 +636,11 @@
|
||||
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
||||
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
||||
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
||||
<div class="w-44" title={t('dashboard.autoRefreshTitle')}>
|
||||
<IconGridSelect items={refreshIntervalItems()}
|
||||
bind:value={refreshSeconds}
|
||||
columns={5} compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet paginator()}
|
||||
@@ -597,8 +684,11 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="signal-list stagger-children">
|
||||
{#each status.recent_events as event, i}
|
||||
<div class="signal-row" style="animation-delay: {i * 60}ms;">
|
||||
{#each status.recent_events as event, i (event.id)}
|
||||
<button type="button" class="signal-row signal-row--clickable"
|
||||
style="animation-delay: {i * 60}ms;"
|
||||
onclick={() => selectedEvent = event}
|
||||
aria-label={t('events.detailTitle')}>
|
||||
<div class="signal-avatar"
|
||||
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
|
||||
@@ -615,7 +705,29 @@
|
||||
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.tracker_name}
|
||||
{#if event.event_type?.startsWith('command_')}
|
||||
{@const issuer = event.details?.issuer as { id?: number; username?: string; first_name?: string; last_name?: string } | undefined}
|
||||
{@const issuerLabel = issuer
|
||||
? (issuer.username ? '@' + issuer.username : [issuer.first_name, issuer.last_name].filter(Boolean).join(' ') || ('id ' + issuer.id))
|
||||
: ''}
|
||||
<div class="signal-trail">
|
||||
{#if event.bot_name}
|
||||
<span class="ch"><MdiIcon name="mdiRobotHappy" size={11} />{event.bot_name}</span>
|
||||
{/if}
|
||||
{#if event.collection_id}
|
||||
{#if event.bot_name}<span class="arrow">→</span>{/if}
|
||||
<span class="ch"><MdiIcon name="mdiChatProcessing" size={11} />{event.collection_id}</span>
|
||||
{/if}
|
||||
{#if issuerLabel}
|
||||
<span class="arrow">→</span>
|
||||
<span class="ch"><MdiIcon name="mdiAccount" size={11} />{issuerLabel}</span>
|
||||
{/if}
|
||||
{#if event.provider_name}
|
||||
<span class="arrow">→</span>
|
||||
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if event.tracker_name}
|
||||
<div class="signal-trail">
|
||||
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
|
||||
{#if event.provider_name}
|
||||
@@ -629,7 +741,7 @@
|
||||
<b>{timeShort(event.created_at)}</b>
|
||||
<small>{timeAgo(event.created_at)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -790,6 +902,8 @@
|
||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||
|
||||
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
|
||||
|
||||
<style>
|
||||
/* ============================================================
|
||||
HERO
|
||||
@@ -1129,6 +1243,20 @@
|
||||
}
|
||||
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
|
||||
.signal-row:hover { background: var(--color-glass-strong); }
|
||||
/* Row is rendered as <button> for clickability — strip default chrome
|
||||
and align children left like the prior <div> layout. */
|
||||
.signal-row--clickable {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.signal-row--clickable:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.signal-avatar {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
title={t('actions.title')}
|
||||
emphasis={t('actions.titleEmphasis')}
|
||||
description={t('actions.description')}
|
||||
crumb="Routing · Automation"
|
||||
crumb={t('crumbs.routingAutomation')}
|
||||
count={actions.length}
|
||||
countLabel={t('actions.countLabel')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
title={t('emailBot.title')}
|
||||
emphasis={t('emailBot.titleEmphasis')}
|
||||
description={t('emailBot.description')}
|
||||
crumb="Operators · Bots"
|
||||
crumb={t('crumbs.operatorsBots')}
|
||||
count={emailBots.length}
|
||||
countLabel={t('emailBot.countLabel')}
|
||||
>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
title={t('matrixBot.title')}
|
||||
emphasis={t('matrixBot.titleEmphasis')}
|
||||
description={t('matrixBot.description')}
|
||||
crumb="Operators · Bots"
|
||||
crumb={t('crumbs.operatorsBots')}
|
||||
count={matrixBots.length}
|
||||
countLabel={t('matrixBot.countLabel')}
|
||||
>
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
title={t('telegramBot.title')}
|
||||
emphasis={t('telegramBot.titleEmphasis')}
|
||||
description={t('telegramBot.description')}
|
||||
crumb="Operators · Bots"
|
||||
crumb={t('crumbs.operatorsBots')}
|
||||
count={bots.length}
|
||||
countLabel={t('telegramBot.countLabel')}
|
||||
>
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
title={t('commandConfig.title')}
|
||||
emphasis={t('commandConfig.titleEmphasis')}
|
||||
description={t('commandConfig.description')}
|
||||
crumb="Routing · Commands"
|
||||
crumb={t('crumbs.routingCommands')}
|
||||
count={configs.length}
|
||||
countLabel={t('commandConfig.countLabel')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -422,7 +422,7 @@
|
||||
title={t('cmdTemplateConfig.title')}
|
||||
emphasis={t('cmdTemplateConfig.titleEmphasis')}
|
||||
description={t('cmdTemplateConfig.description')}
|
||||
crumb="Routing · Commands"
|
||||
crumb={t('crumbs.routingCommands')}
|
||||
count={configs.length}
|
||||
countLabel={t('cmdTemplateConfig.countLabel')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
title={t('commandTracker.title')}
|
||||
emphasis={t('commandTracker.titleEmphasis')}
|
||||
description={t('commandTracker.description')}
|
||||
crumb="Routing · Commands"
|
||||
crumb={t('crumbs.routingCommands')}
|
||||
count={trackers.length}
|
||||
countLabel={t('dashboard.trackersShort')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -468,7 +468,7 @@
|
||||
title={t('notificationTracker.title')}
|
||||
emphasis={t('notificationTracker.titleEmphasis')}
|
||||
description={t('notificationTracker.description')}
|
||||
crumb="Routing · Notification"
|
||||
crumb={t('crumbs.routingNotification')}
|
||||
count={notificationTrackers.length}
|
||||
countLabel={t('dashboard.trackersShort')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -227,13 +227,22 @@
|
||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||
<div class="flex-1 text-xs">
|
||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||
<a href={form.default_tracking_config_id
|
||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||
: '/tracking-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
<a href={form.default_tracking_config_id
|
||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||
: '/tracking-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
<a href={form.default_template_config_id
|
||||
? `/template-configs?edit=${form.default_template_config_id}`
|
||||
: '/template-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTemplateConfig')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
title={t('providers.title')}
|
||||
emphasis={t('providers.titleEmphasis')}
|
||||
description={t('providers.description')}
|
||||
crumb="Service · Connections"
|
||||
crumb={t('crumbs.serviceConnections')}
|
||||
count={providers.length}
|
||||
countLabel={t('dashboard.providersShort')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
title={t('settings.title')}
|
||||
emphasis={t('settings.titleEmphasis')}
|
||||
description={t('settings.description')}
|
||||
crumb="System · Configuration"
|
||||
crumb={t('crumbs.systemConfiguration')}
|
||||
/>
|
||||
|
||||
{#if !loaded}
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
title={t('backup.title')}
|
||||
emphasis={t('backup.titleEmphasis')}
|
||||
description={t('backup.description')}
|
||||
crumb="System · Maintenance"
|
||||
crumb={t('crumbs.systemMaintenance')}
|
||||
/>
|
||||
|
||||
{#if !loaded}
|
||||
|
||||
@@ -453,7 +453,7 @@
|
||||
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
|
||||
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
|
||||
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
|
||||
crumb="Routing · Targets"
|
||||
crumb={t('crumbs.routingTargets')}
|
||||
count={targets.length}
|
||||
countLabel={t('dashboard.targetsShort')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -261,7 +261,25 @@
|
||||
supportedLocalesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
|
||||
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
|
||||
}
|
||||
|
||||
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
|
||||
// config in edit mode. Mirrors the same hook on tracking-configs so the
|
||||
// Notification Tracker form can link directly to the editor instead of
|
||||
// the generic list. Strips the param afterwards so a browser refresh
|
||||
// doesn't re-open the modal.
|
||||
function _openEditFromUrl() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const editId = params.get('edit');
|
||||
if (!editId) return;
|
||||
const match = allTemplateConfigs.find(c => String(c.id) === editId);
|
||||
if (match) edit(match);
|
||||
params.delete('edit');
|
||||
const qs = params.toString();
|
||||
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +447,7 @@
|
||||
title={t('templateConfig.title')}
|
||||
emphasis={t('templateConfig.titleEmphasis')}
|
||||
description={t('templateConfig.description')}
|
||||
crumb="Routing · Notification"
|
||||
crumb={t('crumbs.routingNotification')}
|
||||
count={configs.length}
|
||||
countLabel={t('templateConfig.countLabel')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
title={t('trackingConfig.title')}
|
||||
emphasis={t('trackingConfig.titleEmphasis')}
|
||||
description={t('trackingConfig.description')}
|
||||
crumb="Routing · Notification"
|
||||
crumb={t('crumbs.routingNotification')}
|
||||
count={configs.length}
|
||||
countLabel={t('trackingConfig.countLabel')}
|
||||
pills={headerPills}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
title={t('users.title')}
|
||||
emphasis={t('users.titleEmphasis')}
|
||||
description={t('users.description')}
|
||||
crumb="System · Access"
|
||||
crumb={t('crumbs.systemAccess')}
|
||||
count={users.length}
|
||||
countLabel={t('users.countLabel')}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -118,6 +118,31 @@ async def get_status(
|
||||
)).all()
|
||||
action_name_map = {aid: aname for aid, aname in action_rows}
|
||||
|
||||
# Live-resolve command tracker and bot names for command_* events
|
||||
# (mirrors the action/tracker pattern above). Falls back to the
|
||||
# snapshot stored on the EventLog when the entity has been deleted.
|
||||
cmd_tracker_ids = {
|
||||
e.command_tracker_id for e in event_rows if e.command_tracker_id is not None
|
||||
}
|
||||
cmd_tracker_name_map: dict[int, str] = {}
|
||||
if cmd_tracker_ids:
|
||||
cmd_tracker_rows = (await session.exec(
|
||||
select(CommandTracker.id, CommandTracker.name).where(
|
||||
CommandTracker.id.in_(cmd_tracker_ids)
|
||||
)
|
||||
)).all()
|
||||
cmd_tracker_name_map = {tid: tname for tid, tname in cmd_tracker_rows}
|
||||
|
||||
bot_ids = {
|
||||
e.telegram_bot_id for e in event_rows if e.telegram_bot_id is not None
|
||||
}
|
||||
bot_name_map: dict[int, str] = {}
|
||||
if bot_ids:
|
||||
bot_rows = (await session.exec(
|
||||
select(TelegramBot.id, TelegramBot.name).where(TelegramBot.id.in_(bot_ids))
|
||||
)).all()
|
||||
bot_name_map = {bid: bname for bid, bname in bot_rows}
|
||||
|
||||
def _display_tracker_name(e: EventLog) -> str:
|
||||
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
|
||||
return tracker_name_map[e.tracker_id]
|
||||
@@ -135,11 +160,30 @@ async def get_status(
|
||||
return f"(deleted) {e.action_name}"
|
||||
return ""
|
||||
|
||||
def _display_command_tracker_name(e: EventLog) -> str:
|
||||
if (
|
||||
e.command_tracker_id is not None
|
||||
and e.command_tracker_id in cmd_tracker_name_map
|
||||
):
|
||||
return cmd_tracker_name_map[e.command_tracker_id]
|
||||
if e.command_tracker_name:
|
||||
return f"(deleted) {e.command_tracker_name}"
|
||||
return ""
|
||||
|
||||
def _display_bot_name(e: EventLog) -> str:
|
||||
if e.telegram_bot_id is not None and e.telegram_bot_id in bot_name_map:
|
||||
return bot_name_map[e.telegram_bot_id]
|
||||
if e.bot_name:
|
||||
return f"(deleted) {e.bot_name}"
|
||||
return ""
|
||||
|
||||
def _display_subject(e: EventLog) -> str:
|
||||
"""The primary label shown on the event row.
|
||||
|
||||
For action events the ``collection_name`` stores the action name;
|
||||
use the live-resolved action name when available so renames show.
|
||||
For command events the ``collection_name`` already stores the
|
||||
rendered ``/cmd args`` string so we just pass it through.
|
||||
"""
|
||||
if e.action_id is not None or (e.event_type or "").startswith("action_"):
|
||||
return _display_action_name(e) or e.collection_name
|
||||
@@ -155,9 +199,14 @@ async def get_status(
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"collection_name": _display_subject(e),
|
||||
"tracker_id": e.tracker_id,
|
||||
"tracker_name": _display_tracker_name(e),
|
||||
"action_id": e.action_id,
|
||||
"action_name": _display_action_name(e),
|
||||
"command_tracker_id": e.command_tracker_id,
|
||||
"command_tracker_name": _display_command_tracker_name(e),
|
||||
"telegram_bot_id": e.telegram_bot_id,
|
||||
"bot_name": _display_bot_name(e),
|
||||
"provider_name": _display_provider_name(e),
|
||||
"provider_id": e.provider_id,
|
||||
"assets_count": e.assets_count or 0,
|
||||
|
||||
@@ -262,6 +262,101 @@ def _merge_enabled_commands(
|
||||
return sorted(enabled), merged_limits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_command_subject(cmd: str, args: str) -> str:
|
||||
"""Render the dashboard ``collection_name`` for a command event."""
|
||||
args = (args or "").strip()
|
||||
return f"/{cmd} {args}".rstrip() if args else f"/{cmd}"
|
||||
|
||||
|
||||
def _normalize_issuer(issuer: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Strip a Telegram ``from`` payload to the fields the dashboard needs.
|
||||
|
||||
Telegram's ``from`` carries plenty we don't want to persist (premium
|
||||
badge, language code already captured elsewhere, etc.). Keep just
|
||||
the identity bits and drop anything else so future Telegram changes
|
||||
can't accidentally start logging extra PII.
|
||||
"""
|
||||
if not issuer:
|
||||
return None
|
||||
keep = ("id", "username", "first_name", "last_name", "is_bot")
|
||||
out = {k: issuer[k] for k in keep if k in issuer and issuer[k] not in (None, "")}
|
||||
return out or None
|
||||
|
||||
|
||||
async def _log_command_event(
|
||||
*,
|
||||
bot: TelegramBot,
|
||||
chat_id: str,
|
||||
cmd: str,
|
||||
args: str,
|
||||
locale: str,
|
||||
event_type: str,
|
||||
responses: list[CommandResponse],
|
||||
ctx_tuples: list[
|
||||
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
|
||||
],
|
||||
extra_details: dict[str, Any] | None = None,
|
||||
issuer: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Persist a single ``EventLog`` row for a bot-command invocation.
|
||||
|
||||
One row per user invocation. Per-tracker breakdown lives in ``details``
|
||||
(``tracker_count`` / ``responses_count``). Best-effort: a logging
|
||||
failure must never block the user-visible reply, so we swallow.
|
||||
"""
|
||||
try:
|
||||
first_tracker: CommandTracker | None = None
|
||||
first_provider: ServiceProvider | None = None
|
||||
if ctx_tuples:
|
||||
first_tracker, _, first_provider, _ = ctx_tuples[0]
|
||||
|
||||
media_total = sum(len(r.media or []) for r in responses)
|
||||
details: dict[str, Any] = {
|
||||
"command": cmd,
|
||||
"args": args or "",
|
||||
"chat_id": chat_id,
|
||||
"locale": locale,
|
||||
"tracker_count": len(ctx_tuples),
|
||||
"responses_count": len(responses),
|
||||
}
|
||||
normalized_issuer = _normalize_issuer(issuer)
|
||||
if normalized_issuer:
|
||||
details["issuer"] = normalized_issuer
|
||||
if extra_details:
|
||||
details.update(extra_details)
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
session.add(EventLog(
|
||||
user_id=bot.user_id,
|
||||
tracker_id=None,
|
||||
tracker_name="",
|
||||
action_id=None,
|
||||
action_name="",
|
||||
command_tracker_id=first_tracker.id if first_tracker else None,
|
||||
command_tracker_name=first_tracker.name if first_tracker else "",
|
||||
telegram_bot_id=bot.id,
|
||||
bot_name=bot.name or "",
|
||||
provider_id=first_provider.id if first_provider else None,
|
||||
provider_name=(first_provider.name if first_provider else "") or "",
|
||||
event_type=event_type,
|
||||
collection_id=str(chat_id),
|
||||
collection_name=_format_command_subject(cmd, args),
|
||||
assets_count=media_total,
|
||||
details=details,
|
||||
))
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — diagnostic only, never block reply
|
||||
_LOGGER.exception(
|
||||
"Failed to log command event bot=%d chat=%s cmd=/%s",
|
||||
bot.id, chat_id, cmd,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -271,12 +366,18 @@ async def handle_command(
|
||||
chat_id: str,
|
||||
text: str,
|
||||
language_code: str = "",
|
||||
*,
|
||||
issuer: dict[str, Any] | None = None,
|
||||
) -> list[CommandResponse] | None:
|
||||
"""Handle a bot command. Routes to provider-specific handlers.
|
||||
|
||||
Returns a list of CommandResponse objects (one per tracker), or None.
|
||||
Universal commands (/start, /help) return a single-element list.
|
||||
Provider-specific commands dispatch per-tracker with per-tracker config.
|
||||
|
||||
``issuer`` is the Telegram ``from`` object (``{id, username,
|
||||
first_name, last_name, language_code}``) when known. Stored on the
|
||||
EventLog row so the dashboard can show *who* invoked the command.
|
||||
"""
|
||||
cmd, args, count_override = parse_command(text)
|
||||
if not cmd:
|
||||
@@ -292,10 +393,20 @@ async def handle_command(
|
||||
# Merged templates for universal commands
|
||||
merged_templates = _merge_all_templates(templates_by_config_id)
|
||||
|
||||
# Universal commands have no tracker/provider context.
|
||||
if cmd == "start":
|
||||
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
|
||||
return [CommandResponse(text=text_resp)]
|
||||
responses = [CommandResponse(text=text_resp)]
|
||||
await _log_command_event(
|
||||
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
|
||||
event_type="command_handled", responses=responses,
|
||||
ctx_tuples=[], issuer=issuer,
|
||||
)
|
||||
return responses
|
||||
|
||||
# Unknown / disabled command — caller treats this the same as "no
|
||||
# match" and we deliberately do NOT log it (avoids dashboard spam
|
||||
# from random ``/foo`` traffic).
|
||||
if cmd not in enabled and cmd != "start":
|
||||
return None
|
||||
|
||||
@@ -307,13 +418,26 @@ async def handle_command(
|
||||
cmd, bot.id, chat_id, wait,
|
||||
)
|
||||
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
|
||||
return [CommandResponse(text=text_resp)]
|
||||
responses = [CommandResponse(text=text_resp)]
|
||||
await _log_command_event(
|
||||
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
|
||||
event_type="command_rate_limited", responses=responses,
|
||||
ctx_tuples=ctx_tuples, extra_details={"wait_seconds": wait},
|
||||
issuer=issuer,
|
||||
)
|
||||
return responses
|
||||
|
||||
# Universal commands — single merged response
|
||||
if cmd == "help":
|
||||
ctx = _cmd_help(enabled, locale, merged_templates)
|
||||
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
|
||||
return [CommandResponse(text=text_resp)]
|
||||
responses = [CommandResponse(text=text_resp)]
|
||||
await _log_command_event(
|
||||
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
|
||||
event_type="command_handled", responses=responses,
|
||||
ctx_tuples=ctx_tuples, issuer=issuer,
|
||||
)
|
||||
return responses
|
||||
|
||||
# Provider-specific dispatch — per-tracker
|
||||
from .dispatch import get_handler
|
||||
@@ -329,48 +453,69 @@ async def handle_command(
|
||||
from .command_utils import resolve_chat_album_scope
|
||||
|
||||
responses: list[CommandResponse] = []
|
||||
for tracker, config, provider, listener in ctx_tuples:
|
||||
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
|
||||
_LOGGER.warning(
|
||||
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
|
||||
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
|
||||
dispatched_ctx: list[
|
||||
tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]
|
||||
] = []
|
||||
try:
|
||||
for tracker, config, provider, listener in ctx_tuples:
|
||||
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
|
||||
_LOGGER.warning(
|
||||
"Truncated command responses at %d for bot=%d chat=%s cmd=/%s (listener context size=%d)",
|
||||
_MAX_RESPONSES_PER_COMMAND, bot.id, chat_id, cmd, len(ctx_tuples),
|
||||
)
|
||||
break
|
||||
|
||||
handler = get_handler(provider.type)
|
||||
if not handler or cmd not in handler.get_provider_commands():
|
||||
continue
|
||||
|
||||
tracker_templates = _templates_for_config(templates_by_config_id, config)
|
||||
count = min(count_override or config.default_count or 5, 20)
|
||||
response_mode = config.response_mode or "media"
|
||||
|
||||
# Resolve the album scope for this (provider, bot, chat) triple.
|
||||
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
|
||||
# - Otherwise derive from notification routing: only albums that
|
||||
# already deliver notifications to this chat are queryable from
|
||||
# it. Prevents commands leaking the full album catalog into
|
||||
# chats that were never set up to receive from those trackers.
|
||||
if listener is not None and listener.allowed_album_ids is not None:
|
||||
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
|
||||
else:
|
||||
allowed_album_ids = await resolve_chat_album_scope(
|
||||
provider_id=provider.id,
|
||||
bot_id=bot.id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, tracker_templates, bot, tracker, config,
|
||||
listener=listener,
|
||||
allowed_album_ids=allowed_album_ids,
|
||||
page=page,
|
||||
)
|
||||
break
|
||||
|
||||
handler = get_handler(provider.type)
|
||||
if not handler or cmd not in handler.get_provider_commands():
|
||||
continue
|
||||
|
||||
tracker_templates = _templates_for_config(templates_by_config_id, config)
|
||||
count = min(count_override or config.default_count or 5, 20)
|
||||
response_mode = config.response_mode or "media"
|
||||
|
||||
# Resolve the album scope for this (provider, bot, chat) triple.
|
||||
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
|
||||
# - Otherwise derive from notification routing: only albums that
|
||||
# already deliver notifications to this chat are queryable from
|
||||
# it. Prevents commands leaking the full album catalog into
|
||||
# chats that were never set up to receive from those trackers.
|
||||
if listener is not None and listener.allowed_album_ids is not None:
|
||||
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
|
||||
else:
|
||||
allowed_album_ids = await resolve_chat_album_scope(
|
||||
provider_id=provider.id,
|
||||
bot_id=bot.id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, tracker_templates, bot, tracker, config,
|
||||
listener=listener,
|
||||
allowed_album_ids=allowed_album_ids,
|
||||
page=page,
|
||||
if result is not None:
|
||||
responses.append(result)
|
||||
dispatched_ctx.append((tracker, config, provider, listener))
|
||||
except Exception as exc: # noqa: BLE001 — log then re-raise
|
||||
await _log_command_event(
|
||||
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
|
||||
event_type="command_failed", responses=responses,
|
||||
ctx_tuples=ctx_tuples,
|
||||
extra_details={"error": f"{type(exc).__name__}: {exc}"},
|
||||
issuer=issuer,
|
||||
)
|
||||
if result is not None:
|
||||
responses.append(result)
|
||||
raise
|
||||
|
||||
return responses if responses else None
|
||||
if responses:
|
||||
await _log_command_event(
|
||||
bot=bot, chat_id=chat_id, cmd=cmd, args=args, locale=locale,
|
||||
event_type="command_handled", responses=responses,
|
||||
ctx_tuples=dispatched_ctx, issuer=issuer,
|
||||
)
|
||||
return responses
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_help(
|
||||
|
||||
@@ -120,7 +120,11 @@ async def telegram_webhook(
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
|
||||
responses = await handle_command(
|
||||
bot, chat_id, text,
|
||||
language_code=effective_lang,
|
||||
issuer=from_user or None,
|
||||
)
|
||||
if not responses:
|
||||
_LOGGER.info(
|
||||
"Command produced no response (cmd=%r) after %.0f ms",
|
||||
|
||||
@@ -90,6 +90,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
|
||||
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
|
||||
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
|
||||
("command_tracker_id", "ALTER TABLE event_log ADD COLUMN command_tracker_id INTEGER"),
|
||||
("command_tracker_name", "ALTER TABLE event_log ADD COLUMN command_tracker_name TEXT DEFAULT ''"),
|
||||
("telegram_bot_id", "ALTER TABLE event_log ADD COLUMN telegram_bot_id INTEGER"),
|
||||
("bot_name", "ALTER TABLE event_log ADD COLUMN bot_name TEXT DEFAULT ''"),
|
||||
]:
|
||||
if not await _has_column(conn, "event_log", col):
|
||||
await conn.execute(text(sql))
|
||||
@@ -105,6 +109,8 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
("ix_event_log_user_id", "user_id"),
|
||||
("ix_event_log_action_id", "action_id"),
|
||||
("ix_event_log_provider_id", "provider_id"),
|
||||
("ix_event_log_command_tracker_id", "command_tracker_id"),
|
||||
("ix_event_log_telegram_bot_id", "telegram_bot_id"),
|
||||
]:
|
||||
await conn.execute(
|
||||
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
|
||||
|
||||
@@ -519,6 +519,17 @@ class EventLog(SQLModel, table=True):
|
||||
default=None, foreign_key="action.id", index=True,
|
||||
)
|
||||
action_name: str = Field(default="")
|
||||
# Bot command provenance. Populated when ``event_type`` starts with
|
||||
# ``command_`` so the dashboard can render command activity alongside
|
||||
# tracker and action events. NULL for non-command rows.
|
||||
command_tracker_id: int | None = Field(
|
||||
default=None, foreign_key="command_tracker.id", index=True,
|
||||
)
|
||||
command_tracker_name: str = Field(default="")
|
||||
telegram_bot_id: int | None = Field(
|
||||
default=None, foreign_key="telegram_bot.id", index=True,
|
||||
)
|
||||
bot_name: str = Field(default="")
|
||||
provider_id: int | None = Field(default=None, index=True)
|
||||
provider_name: str = Field(default="")
|
||||
event_type: str = Field(index=True)
|
||||
|
||||
@@ -232,7 +232,9 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
# Copy attributes before session closes to avoid detached-instance errors
|
||||
from types import SimpleNamespace
|
||||
bot_token = bot.token
|
||||
bot_obj = SimpleNamespace(id=bot.id, name=bot.name, token=bot.token)
|
||||
bot_obj = SimpleNamespace(
|
||||
id=bot.id, name=bot.name, token=bot.token, user_id=bot.user_id,
|
||||
)
|
||||
|
||||
offset = _last_update_id.get(bot_id, 0)
|
||||
|
||||
@@ -331,7 +333,11 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
|
||||
responses = await handle_command(
|
||||
bot_obj, chat_id, text,
|
||||
language_code=effective_lang,
|
||||
issuer=from_user or None,
|
||||
)
|
||||
if not responses:
|
||||
_LOGGER.info(
|
||||
"Command produced no response (cmd=%r, poll) after %.0f ms",
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Bot command invocations must be logged to ``EventLog``.
|
||||
|
||||
Covers the three branches in ``handle_command``:
|
||||
|
||||
* ``command_handled`` — a successful invocation (here exercised via the
|
||||
helper directly so the test stays focused on the persistence shape).
|
||||
* ``command_rate_limited`` — caller hit the cooldown.
|
||||
* ``command_failed`` — an exception bubbled out of dispatch.
|
||||
|
||||
The dashboard reads these rows via ``GET /api/status`` so the test also
|
||||
asserts the row is filterable by ``event_type=command_*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
def _bootstrap_app():
|
||||
"""Bring up the app once so migrations run against the temp DB."""
|
||||
from notify_bridge_server.main import app
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _seed_user_and_bot(name: str = "Test bot"):
|
||||
"""Create a User + TelegramBot, return the bot row."""
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import TelegramBot, User
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
user = User(username=f"u_{name}", hashed_password="x")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
bot = TelegramBot(user_id=user.id, name=name, token="dummy")
|
||||
session.add(bot)
|
||||
await session.commit()
|
||||
await session.refresh(bot)
|
||||
return bot
|
||||
|
||||
|
||||
async def _read_events(event_type: str, bot_id: int):
|
||||
"""Filter by bot_id so tests don't leak rows into each other.
|
||||
|
||||
The temp DB is shared across tests in this module — without this
|
||||
filter a row left by an earlier test would make the next assertion
|
||||
flaky depending on collection order.
|
||||
"""
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import EventLog
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.event_type == event_type)
|
||||
.where(EventLog.telegram_bot_id == bot_id)
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
|
||||
def test_format_command_subject_no_args(tmp_data_dir) -> None: # noqa: ARG001
|
||||
from notify_bridge_server.commands.handler import _format_command_subject
|
||||
|
||||
assert _format_command_subject("latest", "") == "/latest"
|
||||
assert _format_command_subject("help", None) == "/help"
|
||||
|
||||
|
||||
def test_format_command_subject_with_args(tmp_data_dir) -> None: # noqa: ARG001
|
||||
from notify_bridge_server.commands.handler import _format_command_subject
|
||||
|
||||
assert _format_command_subject("search", "sunset") == "/search sunset"
|
||||
# Trailing whitespace must not leak into the dashboard label.
|
||||
assert _format_command_subject("search", "sunset ") == "/search sunset"
|
||||
|
||||
|
||||
def test_normalize_issuer_keeps_identity_drops_extras(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""Telegram ``from`` is whitelisted to identity fields only."""
|
||||
from notify_bridge_server.commands.handler import _normalize_issuer
|
||||
|
||||
assert _normalize_issuer(None) is None
|
||||
assert _normalize_issuer({}) is None
|
||||
raw = {
|
||||
"id": 1234,
|
||||
"username": "alex",
|
||||
"first_name": "Alex",
|
||||
"last_name": "",
|
||||
"language_code": "ru", # already captured separately — must drop
|
||||
"is_premium": True, # must not leak into our log
|
||||
}
|
||||
assert _normalize_issuer(raw) == {
|
||||
"id": 1234,
|
||||
"username": "alex",
|
||||
"first_name": "Alex",
|
||||
}
|
||||
|
||||
|
||||
def test_log_command_handled_persists_row(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""``command_handled`` row carries bot + provenance + media count."""
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("HandledBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="123456",
|
||||
cmd="latest",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_handled",
|
||||
responses=[CommandResponse(text="ok", media=[{"type": "photo"}])],
|
||||
ctx_tuples=[], # universal command path: no tracker context
|
||||
)
|
||||
rows = await _read_events("command_handled", bot.id)
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.user_id == bot.user_id
|
||||
assert row.telegram_bot_id == bot.id
|
||||
assert row.bot_name == "HandledBot"
|
||||
assert row.collection_id == "123456"
|
||||
assert row.collection_name == "/latest"
|
||||
assert row.assets_count == 1
|
||||
assert row.details["command"] == "latest"
|
||||
assert row.details["chat_id"] == "123456"
|
||||
assert row.details["responses_count"] == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_rate_limited_carries_wait_seconds(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("ThrottledBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="42",
|
||||
cmd="random",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_rate_limited",
|
||||
responses=[CommandResponse(text="cooldown")],
|
||||
ctx_tuples=[],
|
||||
extra_details={"wait_seconds": 7},
|
||||
)
|
||||
rows = await _read_events("command_rate_limited", bot.id)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].details["wait_seconds"] == 7
|
||||
assert rows[0].assets_count == 0 # text-only response
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_failed_records_error(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands.handler import _log_command_event
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("BrokenBot")
|
||||
await _log_command_event(
|
||||
bot=bot,
|
||||
chat_id="9",
|
||||
cmd="albums",
|
||||
args="",
|
||||
locale="ru",
|
||||
event_type="command_failed",
|
||||
responses=[],
|
||||
ctx_tuples=[],
|
||||
extra_details={"error": "RuntimeError: boom"},
|
||||
)
|
||||
rows = await _read_events("command_failed", bot.id)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].details["error"] == "RuntimeError: boom"
|
||||
assert rows[0].details["locale"] == "ru"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_log_command_event_handles_db_error_gracefully(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
|
||||
"""A logging failure must NOT raise — the user still gets their reply."""
|
||||
import asyncio
|
||||
|
||||
from notify_bridge_server.commands import handler as handler_mod
|
||||
from notify_bridge_server.commands.base import CommandResponse
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
bot = await _seed_user_and_bot("StillRepliesBot")
|
||||
|
||||
def boom() -> object:
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
monkeypatch.setattr(handler_mod, "get_engine", boom)
|
||||
|
||||
# Must not raise.
|
||||
await handler_mod._log_command_event(
|
||||
bot=bot,
|
||||
chat_id="1",
|
||||
cmd="help",
|
||||
args="",
|
||||
locale="en",
|
||||
event_type="command_handled",
|
||||
responses=[CommandResponse(text="hi")],
|
||||
ctx_tuples=[],
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user