diff --git a/.changeset/lazy-pants-cover.md b/.changeset/lazy-pants-cover.md new file mode 100644 index 0000000000..3824d085e7 --- /dev/null +++ b/.changeset/lazy-pants-cover.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +[web] Add view to display a list of all events diff --git a/packages/web-shared/src/event-list-view.tsx b/packages/web-shared/src/event-list-view.tsx new file mode 100644 index 0000000000..be44b2c8ec --- /dev/null +++ b/packages/web-shared/src/event-list-view.tsx @@ -0,0 +1,435 @@ +'use client'; + +import type { Event } from '@workflow/world'; +import { ChevronRight, Loader2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import type { EnvMap } from './api/workflow-server-actions'; +import { fetchEventsByCorrelationId } from './api/workflow-server-actions'; +import { getEventColor } from './workflow-traces/event-colors'; + +/** + * Format a date to a human-readable local time string with milliseconds + */ +function formatEventTime(date: Date): string { + return ( + date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + '.' + + date.getMilliseconds().toString().padStart(3, '0') + ); +} + +/** + * Format a date to full local datetime string with milliseconds + */ +function formatEventDateTime(date: Date): string { + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + fractionalSecondDigits: 3, + }); +} + +/** + * Format event type to a more readable label + */ +function formatEventType(eventType: Event['eventType']): string { + return eventType + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +interface EventsListProps { + events: Event[] | null; + env: EnvMap; +} + +/** + * Single event row component with expandable details + */ +function EventRow({ event, env }: { event: Event; env: EnvMap }) { + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadedEventData, setLoadedEventData] = useState(null); + const [loadError, setLoadError] = useState(null); + + const colors = getEventColor(event.eventType); + const createdAt = new Date(event.createdAt); + + // Check if event already has eventData (from initial fetch) + const hasExistingEventData = 'eventData' in event && event.eventData != null; + + // Load full event details when expanding + const loadEventDetails = useCallback(async () => { + // Skip if we already have data or no correlationId + if ( + loadedEventData !== null || + hasExistingEventData || + !event.correlationId + ) { + return; + } + + setIsLoading(true); + setLoadError(null); + + try { + const result = await fetchEventsByCorrelationId( + env, + event.correlationId, + { + sortOrder: 'asc', + limit: 100, + withData: true, + } + ); + + if (!result.success) { + setLoadError(result.error?.message || 'Failed to load event details'); + return; + } + + // Find our specific event in the results + const fullEvent = result.data.data.find( + (e) => e.eventId === event.eventId + ); + if (fullEvent && 'eventData' in fullEvent) { + setLoadedEventData(fullEvent.eventData); + } + } catch (err) { + setLoadError( + err instanceof Error ? err.message : 'Failed to load event details' + ); + } finally { + setIsLoading(false); + } + }, [ + env, + event.correlationId, + event.eventId, + loadedEventData, + hasExistingEventData, + ]); + + // Handle expand/collapse + const handleToggle = useCallback(() => { + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + + // Load details when expanding for the first time + if (newExpanded && loadedEventData === null && !hasExistingEventData) { + loadEventDetails(); + } + }, [isExpanded, loadedEventData, hasExistingEventData, loadEventDetails]); + + // Get the event data to display (either from initial fetch, loaded data, or null) + const eventData = hasExistingEventData + ? (event as Event & { eventData: unknown }).eventData + : loadedEventData; + + return ( +
+ {/* Clickable row header */} + + + {/* Expanded details */} + {isExpanded && ( +
+ {/* Event attributes in a structured table */} +
+ + + + + +
+ + {/* Event data section */} +
+
+ Event Data +
+ + {/* Loading state */} + {isLoading && ( +
+ + + Loading event details... + +
+ )} + + {/* Error state */} + {loadError && !isLoading && ( +
+ {loadError} +
+ )} + + {/* Event data display */} + {!isLoading && !loadError && eventData != null && ( +
+                {JSON.stringify(eventData, null, 2)}
+              
+ )} + + {/* No event data */} + {!isLoading && + !loadError && + eventData == null && + !event.correlationId && ( +
+ No event data available +
+ )} + + {/* No correlation ID - can't load data */} + {!isLoading && + !loadError && + eventData == null && + event.correlationId && + !hasExistingEventData && + loadedEventData === null && ( +
+ No event data for this event type +
+ )} +
+
+ )} +
+ ); +} + +/** + * Helper component for attribute rows in the expanded details + */ +function AttributeRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +/** + * Displays a list of all events for a workflow run as colored cards in a pseudo-table. + * Events are sorted by createdAt (oldest first). + */ +export function EventListView({ events, env }: EventsListProps) { + // Sort events by createdAt (oldest first) + const sortedEvents = useMemo(() => { + if (!events || events.length === 0) return []; + return [...events].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + }, [events]); + + if (!events || events.length === 0) { + return ( +
+ No events found +
+ ); + } + + return ( +
+ {/* Header row */} +
+
{/* Expand icon column */}
+
Time
+
Event Type
+
Correlation ID
+
Event ID
+
+ + {/* Event rows */} +
+ {sortedEvents.map((event) => ( + + ))} +
+ + {/* Summary */} +
+ {sortedEvents.length} event{sortedEvents.length !== 1 ? 's' : ''} total +
+
+ ); +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 30ad3827a7..03983a877b 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -7,6 +7,7 @@ export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; export type { EnvMap, PublicServerConfig } from './api/workflow-server-actions'; export { ErrorBoundary } from './error-boundary'; +export { EventListView } from './event-list-view'; export type { HookActionCallbacks, HookActionsDropdownItemProps, diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index b65d098278..a4957c0c5f 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -6,6 +6,7 @@ import { type EnvMap, ErrorBoundary, type Event, + EventListView, recreateRun, type Step, StreamViewer, @@ -154,6 +155,8 @@ interface RunDetailViewProps { selectedId?: string; } +type Tab = 'trace' | 'graph' | 'streams' | 'events'; + export function RunDetailView({ runId, // TODO: This should open the right sidebar within the trace viewer @@ -170,8 +173,7 @@ export function RunDetailView({ const env: EnvMap = useMemo(() => ({}), []); // Read tab and streamId from URL search params - const activeTab = - (searchParams.get('tab') as 'trace' | 'graph' | 'streams') || 'trace'; + const activeTab = (searchParams.get('tab') as Tab) || 'trace'; const selectedStreamId = searchParams.get('streamId'); // Helper to update URL search params @@ -191,7 +193,7 @@ export function RunDetailView({ ); const setActiveTab = useCallback( - (tab: 'trace' | 'graph' | 'streams') => { + (tab: Tab) => { // When switching to trace or graph tab, clear streamId if (tab === 'trace' || tab === 'graph') { updateSearchParams({ tab, streamId: null }); @@ -525,9 +527,7 @@ export function RunDetailView({
- setActiveTab(v as 'trace' | 'graph' | 'streams') - } + onValueChange={(v) => setActiveTab(v as Tab)} className="flex-1 flex flex-col min-h-0" > @@ -541,6 +541,10 @@ export function RunDetailView({ Graph )} + + + Events + Streams @@ -564,6 +568,14 @@ export function RunDetailView({ + + +
+ +
+
+
+