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:
@@ -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