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:
2026-05-07 22:22:41 +03:00
parent 632e4c1aa3
commit 35a3008896
13 changed files with 952 additions and 50 deletions
@@ -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>
+16
View File
@@ -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[] => [
+40
View File
@@ -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",
+40
View File
@@ -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": "Индикатор не показывается",
+7
View File
@@ -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;
+102 -5
View File
@@ -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;