feat: log bot command invocations to the event stream
Bot commands were the only user-initiated path that didn't surface in the dashboard. They now produce ``command_handled`` / ``command_rate_limited`` / ``command_failed`` rows in ``EventLog`` alongside tracker and action events. Backend - ``EventLog`` gains nullable ``command_tracker_id`` / ``telegram_bot_id`` FKs plus deletion-snapshot name columns (idempotent migration). - New ``_log_command_event`` helper emits one row per invocation at the three branches in ``handle_command``. Logging failures are swallowed so they cannot block the user-visible reply. - Telegram ``from`` is captured in poller and webhook, whitelisted to identity fields by ``_normalize_issuer`` (drops ``language_code`` and any future PII), persisted under ``details.issuer``. - ``/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. Frontend - Event rows are now clickable and open a detail modal with full provenance (bot → chat → issuer → provider), raw ``details`` JSON, and per-entity action buttons. - Buttons use the existing ``requestHighlight`` + ``goto`` crosslink pattern, so clicking lands on the entity's list page with that specific card scrolled into view and pulsing. - Auto-refresh dropdown (Off / 10s / 30s / 1m / 5m) persisted in ``localStorage``; ticker pauses while the tab is hidden. - Event-type filter, dashboard verb labels, and gradients extended for the three new ``command_*`` types. - Filled in pre-existing missing i18n keys (``common.hide`` / ``common.show`` / ``commandConfig.noCommandsForProvider``). Tests - New ``test_command_event_logging.py`` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
This commit is contained in:
@@ -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[] => [
|
||||
|
||||
@@ -87,6 +87,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 +106,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 +153,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",
|
||||
@@ -889,6 +918,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 +1055,8 @@
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"confirm": "Confirm",
|
||||
"cannotDelete": "Cannot delete",
|
||||
"blockedByIntro": "Referenced by:",
|
||||
@@ -1149,6 +1181,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",
|
||||
|
||||
@@ -87,6 +87,15 @@
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
"commandHandled": "команда обработана",
|
||||
"commandRateLimited": "ограничение частоты",
|
||||
"commandFailed": "команда упала",
|
||||
"autoRefreshTitle": "Интервал авто-обновления списка событий",
|
||||
"refreshOff": "Выкл",
|
||||
"refresh10s": "10с",
|
||||
"refresh30s": "30с",
|
||||
"refresh60s": "1м",
|
||||
"refresh5m": "5м",
|
||||
"searchEvents": "Поиск событий...",
|
||||
"allEvents": "Все события",
|
||||
"filterAssetsAdded": "Добавление файлов",
|
||||
@@ -97,6 +106,9 @@
|
||||
"filterActionSuccess": "Действие выполнено",
|
||||
"filterActionPartial": "Действие частично",
|
||||
"filterActionFailed": "Действие провалено",
|
||||
"filterCommandHandled": "Команда обработана",
|
||||
"filterCommandRateLimited": "Ограничение частоты",
|
||||
"filterCommandFailed": "Команда упала",
|
||||
"allProviders": "Все провайдеры",
|
||||
"newestFirst": "Сначала новые",
|
||||
"oldestFirst": "Сначала старые",
|
||||
@@ -141,6 +153,23 @@
|
||||
"newTracker": "Новый трекер",
|
||||
"eventsTotal": "Событий"
|
||||
},
|
||||
"events": {
|
||||
"detailTitle": "Детали события",
|
||||
"bot": "Бот",
|
||||
"chat": "Чат",
|
||||
"issuer": "Отправитель",
|
||||
"commandTracker": "Командный трекер",
|
||||
"tracker": "Трекер",
|
||||
"action": "Действие",
|
||||
"provider": "Провайдер",
|
||||
"assetsCount": "Файлов",
|
||||
"openProvider": "Открыть провайдера",
|
||||
"openBot": "Открыть бота",
|
||||
"openCommandTracker": "Открыть командный трекер",
|
||||
"openAction": "Открыть действие",
|
||||
"openTracker": "Открыть трекер",
|
||||
"rawDetails": "Сырые данные"
|
||||
},
|
||||
"providers": {
|
||||
"title": "Сервисные",
|
||||
"titleEmphasis": "провайдеры",
|
||||
@@ -889,6 +918,7 @@
|
||||
"titleEmphasis": "конфигурации",
|
||||
"countLabel": "конфигураций",
|
||||
"title": "Конфигурации команд",
|
||||
"noCommandsForProvider": "Для этого типа провайдера нет доступных команд.",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
@@ -1025,6 +1055,8 @@
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"hide": "Скрыть",
|
||||
"show": "Показать",
|
||||
"confirm": "Подтвердить",
|
||||
"cannotDelete": "Невозможно удалить",
|
||||
"blockedByIntro": "На объект ссылаются:",
|
||||
@@ -1149,6 +1181,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();
|
||||
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 {
|
||||
@@ -360,6 +404,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 +414,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 +428,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 +605,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()}
|
||||
@@ -598,7 +654,10 @@
|
||||
{:else}
|
||||
<div class="signal-list stagger-children">
|
||||
{#each status.recent_events as event, i}
|
||||
<div class="signal-row" style="animation-delay: {i * 60}ms;">
|
||||
<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 +674,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 +710,7 @@
|
||||
<b>{timeShort(event.created_at)}</b>
|
||||
<small>{timeAgo(event.created_at)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -790,6 +871,8 @@
|
||||
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||
|
||||
<EventDetailModal event={selectedEvent} onclose={() => selectedEvent = null} />
|
||||
|
||||
<style>
|
||||
/* ============================================================
|
||||
HERO
|
||||
@@ -1129,6 +1212,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;
|
||||
|
||||
Reference in New Issue
Block a user