/** * Events Explorer Page (Story 8.1) * * Route: /events (replaces placeholder Events page) * - Table columns: Timestamp, Type, Name, Agent, Session, Level, Duration * - Filter bar: event type multi-select, severity multi-select, agent dropdown, time range * - Free-text search * - Click row → expand inline with payload preview * - Pagination controls */ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import type { EventType, EventSeverity, AgentLensEvent, EventQuery, Agent, } from '@agentkitai/agentlens-core'; import { EVENT_TYPES, EVENT_SEVERITIES } from '@agentkitai/agentlens-core'; import { otlpTitle } from '../lib/otlpEvent'; import { getEvents, getAgents } from '../api/client'; import { useApi } from '../hooks/useApi'; import { useSSE } from '../hooks/useSSE'; // ─── Constants ────────────────────────────────────────────── const PAGE_SIZE = 50; const SEVERITY_COLORS: Record = { debug: 'bg-gray-100 text-gray-700', info: 'bg-blue-100 text-blue-700', warn: 'bg-yellow-100 text-yellow-800', error: 'bg-red-100 text-red-700', critical: 'bg-red-200 text-red-900 font-bold', }; const TYPE_LABELS: Record = { session_started: 'Session Started', session_ended: 'Session Ended', tool_call: 'Tool Call', tool_response: 'Tool Response', tool_error: 'Tool Error', approval_requested: 'Approval Requested', approval_granted: 'Approval Granted', approval_denied: 'Approval Denied', approval_expired: 'Approval Expired', form_submitted: 'Form Submitted', form_completed: 'Form Completed', form_expired: 'Form Expired', cost_tracked: 'Cost Tracked', llm_call: 'LLM Call', llm_response: 'LLM Response', alert_triggered: 'Alert Triggered', alert_resolved: 'Alert Resolved', error: 'Error', eval_result: 'Eval Result', skill_activated: 'Skill Activated', custom: 'Custom', }; // ─── Filter State ─────────────────────────────────────────── interface Filters { eventTypes: EventType[]; severities: EventSeverity[]; agentId: string; from: string; to: string; search: string; } const EMPTY_FILTERS: Filters = { eventTypes: [], severities: [], agentId: '', from: '', to: '', search: '', }; // ─── Helpers ──────────────────────────────────────────────── function getEventName(event: AgentLensEvent): string { const ot = otlpTitle(event); if (ot) return ot; const p = event.payload as Record; if ('toolName' in p && typeof p.toolName === 'string') return p.toolName; if ('action' in p && typeof p.action === 'string') return p.action; if ('alertName' in p && typeof p.alertName === 'string') return p.alertName; if ('formName' in p && typeof p.formName === 'string') return p.formName; if ('type' in p && typeof p.type === 'string') return p.type; return '—'; } function getEventDuration(event: AgentLensEvent): string { const p = event.payload as Record; if ('durationMs' in p && typeof p.durationMs === 'number') { return `${p.durationMs.toLocaleString()}ms`; } if ('totalDurationMs' in p && typeof p.totalDurationMs === 'number') { return `${p.totalDurationMs.toLocaleString()}ms`; } if ('expiredAfterMs' in p && typeof p.expiredAfterMs === 'number') { return `${p.expiredAfterMs.toLocaleString()}ms`; } return '—'; } function formatTimestamp(ts: string): string { try { const d = new Date(ts); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { return ts; } } // ─── Multi-Select Dropdown ────────────────────────────────── interface MultiSelectProps { label: string; options: readonly T[]; selected: T[]; onChange: (values: T[]) => void; formatOption?: (opt: T) => string; } function MultiSelect({ label, options, selected, onChange, formatOption, }: MultiSelectProps): React.ReactElement { const [open, setOpen] = useState(false); const toggle = (value: T) => { if (selected.includes(value)) { onChange(selected.filter((v) => v !== value)); } else { onChange([...selected, value]); } }; return (
{open && (
{options.map((opt) => ( ))} {selected.length > 0 && ( )}
)}
); } // ─── Payload Preview ──────────────────────────────────────── function PayloadPreview({ event }: { event: AgentLensEvent }): React.ReactElement { return (
ID: {event.id} Hash: {event.hash.slice(0, 12)}… {event.prevHash && PrevHash: {event.prevHash.slice(0, 12)}…}
{Object.keys(event.metadata).length > 0 && (
Metadata:
                {JSON.stringify(event.metadata, null, 2)}
              
)}
Payload:
              {JSON.stringify(event.payload, null, 2)}
            
); } // ─── Main Component ───────────────────────────────────────── export function EventsExplorer(): React.ReactElement { const [filters, setFilters] = useState(EMPTY_FILTERS); const [page, setPage] = useState(0); const [expandedId, setExpandedId] = useState(null); // SSE live events prepended to the list (Story 14.4) const [liveEvents, setLiveEvents] = useState([]); // Debounce search input (300ms) to avoid firing API calls on every keystroke const [debouncedSearch, setDebouncedSearch] = useState(filters.search); const debounceTimer = useRef>(undefined); useEffect(() => { debounceTimer.current = setTimeout(() => { setDebouncedSearch(filters.search); }, 300); return () => clearTimeout(debounceTimer.current); }, [filters.search]); const query = useMemo((): EventQuery => { const q: EventQuery = { limit: PAGE_SIZE, offset: page * PAGE_SIZE, order: 'desc', }; if (filters.eventTypes.length > 0) q.eventType = filters.eventTypes; if (filters.severities.length > 0) q.severity = filters.severities; if (filters.agentId) q.agentId = filters.agentId; if (filters.from) q.from = new Date(filters.from).toISOString(); if (filters.to) q.to = new Date(filters.to).toISOString(); if (debouncedSearch.trim()) q.search = debouncedSearch.trim(); return q; }, [filters.eventTypes, filters.severities, filters.agentId, filters.from, filters.to, debouncedSearch, page]); const { data: result, loading, error } = useApi( () => getEvents(query), [JSON.stringify(query)], ); const { data: agents } = useApi(() => getAgents(), []); // SSE params: build from current filters for server-side filtering const sseParams = useMemo(() => { const p: Record = {}; if (filters.agentId) p.agentId = filters.agentId; if (filters.eventTypes.length === 1) p.eventType = filters.eventTypes[0]; else if (filters.eventTypes.length > 1) p.eventType = filters.eventTypes.join(','); return p; }, [filters.agentId, filters.eventTypes]); // SSE for live updates (Story 14.4) // Only show live events on page 0 (first page, newest first) const { connected: sseConnected } = useSSE({ url: '/api/stream', params: sseParams, enabled: page === 0, onEvent: useCallback((data: unknown) => { const event = data as AgentLensEvent; setLiveEvents((prev) => { if (prev.some((e) => e.id === event.id)) return prev; // Keep max 50 live events to avoid memory growth const updated = [event, ...prev]; return updated.slice(0, 50); }); }, []), }); // Clear live events when filters/page change (they'll be included in next fetch) useEffect(() => { setLiveEvents([]); }, [JSON.stringify(query)]); const updateFilter = useCallback((key: K, value: Filters[K]) => { setFilters((f) => ({ ...f, [key]: value })); setPage(0); }, []); // Merge live events with fetched results const displayEvents = useMemo(() => { if (!result) return liveEvents; if (page > 0) return result.events; // Only show live events on first page // Prepend live events that aren't already in the result const existingIds = new Set(result.events.map((e) => e.id)); const newLive = liveEvents.filter((e) => !existingIds.has(e.id)); return [...newLive, ...result.events]; }, [result, liveEvents, page]); const totalPages = result ? Math.ceil(result.total / PAGE_SIZE) : 0; return (
{/* Header */}

Events Explorer

{/* SSE Connection Indicator (Story 14.4) */} {sseConnected ? ( ) : ( )}
{result && ( {result.total.toLocaleString()} event{result.total !== 1 ? 's' : ''} {liveEvents.length > 0 && page === 0 && ( +{liveEvents.length} new )} )}
{/* Filter Bar */}
updateFilter('search', e.target.value)} className="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
updateFilter('eventTypes', v)} formatOption={(t) => TYPE_LABELS[t]} /> updateFilter('severities', v)} /> updateFilter('from', e.target.value)} className="rounded border border-gray-300 px-2 py-1.5 text-sm" title="From date" /> updateFilter('to', e.target.value)} className="rounded border border-gray-300 px-2 py-1.5 text-sm" title="To date" /> {JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS) && ( )}
{/* Error */} {error && (
{error}
)} {/* Table */}
{['Timestamp', 'Type', 'Name', 'Agent', 'Session', 'Level', 'Duration'].map( (col) => ( ), )} {loading && !result && ( )} {result && displayEvents.length === 0 && ( )} {displayEvents.map((event) => ( setExpandedId(expandedId === event.id ? null : event.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedId(expandedId === event.id ? null : event.id); } }} className="cursor-pointer hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" > {expandedId === event.id && ( )} ))}
{col}
Loading events…
No events found
{formatTimestamp(event.timestamp)} {event.eventType} {getEventName(event)} {event.agentId} {event.sessionId.slice(0, 8)}… {event.severity} {getEventDuration(event)}
{/* Pagination */} {totalPages > 1 && (
Page {page + 1} of {totalPages}
)}
); }