diff --git a/.changeset/blue-beers-clap.md b/.changeset/blue-beers-clap.md new file mode 100644 index 0000000000..92842ec9f4 --- /dev/null +++ b/.changeset/blue-beers-clap.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Polish workflow observability event list UX diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index a45120a231..caa72924d8 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -12,6 +12,7 @@ import { ErrorStackBlock, isStructuredErrorWithStack, } from './ui/error-stack-block'; +import { MenuDropdown } from './ui/menu-dropdown'; import { Skeleton } from './ui/skeleton'; /** @@ -378,9 +379,11 @@ function TreeGutter({ function CopyableCell({ value, className, + style: styleProp, }: { value: string; className?: string; + style?: React.CSSProperties; }): ReactNode { const [copied, setCopied] = useState(false); const resetCopiedTimeoutRef = useRef(null); @@ -412,7 +415,8 @@ function CopyableCell({ return (
{value || '-'} @@ -583,6 +587,15 @@ function PayloadBlock({ ); } +// ────────────────────────────────────────────────────────────────────────── +// Sort options for the events list +// ────────────────────────────────────────────────────────────────────────── + +const SORT_OPTIONS = [ + { value: 'desc' as const, label: 'Newest' }, + { value: 'asc' as const, label: 'Oldest' }, +]; + // ────────────────────────────────────────────────────────────────────────── // Event row // ────────────────────────────────────────────────────────────────────────── @@ -598,6 +611,11 @@ interface EventsListProps { encryptionKey?: Uint8Array; /** When true, shows a loading state instead of "No events found" for empty lists */ isLoading?: boolean; + /** Sort order for events. Defaults to 'asc'. */ + sortOrder?: 'asc' | 'desc'; + /** Called when the user changes sort order. When provided, the sort dropdown is shown + * and the parent is expected to refetch from the API with the new order. */ + onSortOrderChange?: (order: 'asc' | 'desc') => void; } function EventRow({ @@ -605,6 +623,8 @@ function EventRow({ index, isFirst, isLast, + isExpanded, + onToggleExpand, activeGroupKey, selectedGroupKey, selectedGroupRange, @@ -614,12 +634,16 @@ function EventRow({ onSelectGroup, onHoverGroup, onLoadEventData, + cachedEventData, + onCacheEventData, encryptionKey, }: { event: Event; index: number; isFirst: boolean; isLast: boolean; + isExpanded: boolean; + onToggleExpand: (eventId: string) => void; activeGroupKey?: string; selectedGroupKey?: string; selectedGroupRange: { first: number; last: number } | null; @@ -629,24 +653,22 @@ function EventRow({ onSelectGroup: (groupKey: string | undefined) => void; onHoverGroup: (groupKey: string | undefined) => void; onLoadEventData?: (event: Event) => Promise; + cachedEventData: unknown | null; + onCacheEventData: (eventId: string, data: unknown) => void; encryptionKey?: Uint8Array; }) { - const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [loadedEventData, setLoadedEventData] = useState(null); + const [loadedEventData, setLoadedEventData] = useState( + cachedEventData + ); const [loadError, setLoadError] = useState(null); - const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false); - - const rowGroupKey = - event.correlationId ?? - (isRunLevel(event.eventType) ? '__run__' : undefined); + const [hasAttemptedLoad, setHasAttemptedLoad] = useState( + cachedEventData !== null + ); - // Collapse when a different group gets selected - useEffect(() => { - if (selectedGroupKey !== undefined && selectedGroupKey !== rowGroupKey) { - setIsExpanded(false); - } - }, [selectedGroupKey, rowGroupKey]); + const rowGroupKey = isRunLevel(event.eventType) + ? '__run__' + : (event.correlationId ?? undefined); const statusDotColor = getStatusDotColor(event.eventType); const createdAt = new Date(event.createdAt); @@ -688,9 +710,10 @@ function EventRow({ setLoadError('Event details unavailable'); return; } - const eventData = await onLoadEventData(event); - if (eventData !== null && eventData !== undefined) { - setLoadedEventData(eventData); + const data = await onLoadEventData(event); + if (data !== null && data !== undefined) { + setLoadedEventData(data); + onCacheEventData(event.eventId, data); } } catch (err) { setLoadError( @@ -700,7 +723,27 @@ function EventRow({ setIsLoading(false); setHasAttemptedLoad(true); } - }, [event, loadedEventData, hasExistingEventData, onLoadEventData]); + }, [ + event, + loadedEventData, + hasExistingEventData, + onLoadEventData, + onCacheEventData, + ]); + + // Auto-load event data when remounting in expanded state without cached data + useEffect(() => { + if ( + isExpanded && + loadedEventData === null && + !hasExistingEventData && + !isLoading && + !hasAttemptedLoad + ) { + loadEventDetails(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // When encryption key changes and this event was previously loaded, // re-load to get decrypted data @@ -712,6 +755,7 @@ function EventRow({ .then((data) => { if (data !== null && data !== undefined) { setLoadedEventData(data); + onCacheEventData(event.eventId, data); } setHasAttemptedLoad(true); }) @@ -722,25 +766,23 @@ function EventRow({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [encryptionKey]); - const handleExpandToggle = useCallback( - (e: ReactMouseEvent) => { - e.stopPropagation(); - const newExpanded = !isExpanded; - setIsExpanded(newExpanded); - if (newExpanded && loadedEventData === null && !hasExistingEventData) { - loadEventDetails(); - } - }, - [isExpanded, loadedEventData, hasExistingEventData, loadEventDetails] - ); - const handleRowClick = useCallback(() => { - if (selectedGroupKey === rowGroupKey) { - onSelectGroup(undefined); - } else { - onSelectGroup(rowGroupKey); + onSelectGroup(rowGroupKey === selectedGroupKey ? undefined : rowGroupKey); + onToggleExpand(event.eventId); + if (!isExpanded && loadedEventData === null && !hasExistingEventData) { + loadEventDetails(); } - }, [selectedGroupKey, rowGroupKey, onSelectGroup]); + }, [ + selectedGroupKey, + rowGroupKey, + onSelectGroup, + onToggleExpand, + event.eventId, + isExpanded, + loadedEventData, + hasExistingEventData, + loadEventDetails, + ]); const eventData = hasExistingEventData ? (event as Event & { eventData: unknown }).eventData @@ -762,7 +804,7 @@ function EventRow({ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleRowClick(); }} - className="w-full text-left flex items-center gap-0 text-sm hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer" + className="w-full text-left flex items-center gap-0 text-[13px] hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer" style={{ minHeight: 40 }} > - {/* Expand chevron button */} - +
{/* Time */}
{formatEventTime(createdAt)}
{/* Event Type */} -
+
{eventName} @@ -863,11 +902,16 @@ function EventRow({ {/* Correlation ID */} {/* Event ID */} - +
@@ -974,14 +1018,33 @@ export function EventListView({ onLoadMoreEvents, encryptionKey, isLoading = false, + sortOrder: sortOrderProp, + onSortOrderChange, }: EventsListProps) { + const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( + 'asc' + ); + const effectiveSortOrder = sortOrderProp ?? internalSortOrder; + const handleSortOrderChange = useCallback( + (order: 'asc' | 'desc') => { + if (onSortOrderChange) { + onSortOrderChange(order); + } else { + setInternalSortOrder(order); + } + }, + [onSortOrderChange] + ); + const sortedEvents = useMemo(() => { if (!events || events.length === 0) return []; + const dir = effectiveSortOrder === 'desc' ? -1 : 1; return [...events].sort( (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + dir * + (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) ); - }, [events]); + }, [events, effectiveSortOrder]); const { correlationNameMap, workflowName } = useMemo( () => buildNameMaps(events ?? null, run ?? null), @@ -1008,6 +1071,58 @@ export function EventListView({ const activeGroupKey = selectedGroupKey ?? hoveredGroupKey; + // Expanded state lifted out of EventRow so it survives virtualization + const [expandedEventIds, setExpandedEventIds] = useState>( + () => new Set() + ); + const toggleEventExpanded = useCallback((eventId: string) => { + setExpandedEventIds((prev) => { + const next = new Set(prev); + if (next.has(eventId)) { + next.delete(eventId); + } else { + next.add(eventId); + } + return next; + }); + }, []); + + // Event data cache — ref avoids re-renders when cache updates + const eventDataCacheRef = useRef>(new Map()); + const cacheEventData = useCallback((eventId: string, data: unknown) => { + eventDataCacheRef.current.set(eventId, data); + }, []); + + // Lookup from eventId → groupKey for efficient collapse filtering + const eventGroupKeyMap = useMemo(() => { + const map = new Map(); + for (const ev of sortedEvents) { + const gk = isRunLevel(ev.eventType) + ? '__run__' + : (ev.correlationId ?? ''); + if (gk) map.set(ev.eventId, gk); + } + return map; + }, [sortedEvents]); + + // Collapse expanded events that don't belong to the newly selected group + useEffect(() => { + if (selectedGroupKey === undefined) return; + setExpandedEventIds((prev) => { + if (prev.size === 0) return prev; + let changed = false; + const next = new Set(); + for (const eventId of prev) { + if (eventGroupKeyMap.get(eventId) === selectedGroupKey) { + next.add(eventId); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [selectedGroupKey, eventGroupKeyMap]); + // Compute the row-index range for the active group's connecting lane line. // Only applies to non-run groups (step/hook/wait correlations). const selectedGroupRange = useMemo(() => { @@ -1028,24 +1143,34 @@ export function EventListView({ const searchIndex = useMemo(() => { const entries: { - text: string; + fields: string[]; groupKey?: string; eventId: string; index: number; }[] = []; for (let i = 0; i < sortedEvents.length; i++) { const ev = sortedEvents[i]; + const isRun = isRunLevel(ev.eventType); + const name = isRun + ? (workflowName ?? '') + : ev.correlationId + ? (correlationNameMap.get(ev.correlationId) ?? '') + : ''; entries.push({ - text: [ev.eventId, ev.correlationId ?? ''].join(' ').toLowerCase(), - groupKey: - ev.correlationId ?? - (isRunLevel(ev.eventType) ? '__run__' : undefined), + fields: [ + ev.eventId, + ev.correlationId ?? '', + ev.eventType, + formatEventType(ev.eventType), + name, + ].map((f) => f.toLowerCase()), + groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined), eventId: ev.eventId, index: i, }); } return entries; - }, [sortedEvents]); + }, [sortedEvents, correlationNameMap, workflowName]); useEffect(() => { const q = searchQuery.trim().toLowerCase(); @@ -1053,11 +1178,23 @@ export function EventListView({ setSelectedGroupKey(undefined); return; } - const match = searchIndex.find((entry) => entry.text.includes(q)); - if (match) { - setSelectedGroupKey(match.groupKey); + let bestMatch: (typeof searchIndex)[number] | null = null; + let bestScore = 0; + for (const entry of searchIndex) { + for (const field of entry.fields) { + if (field && field.includes(q)) { + const score = q.length / field.length; + if (score > bestScore) { + bestScore = score; + bestMatch = entry; + } + } + } + } + if (bestMatch) { + setSelectedGroupKey(bestMatch.groupKey); virtuosoRef.current?.scrollToIndex({ - index: match.index, + index: bestMatch.index, align: 'center', behavior: 'smooth', }); @@ -1065,12 +1202,57 @@ export function EventListView({ }, [searchQuery, searchIndex]); if (!events || events.length === 0) { + if (isLoading) { + return ( +
+ {/* Skeleton search bar */} +
+ +
+ {/* Skeleton header */} +
+ +
+ +
+ +
+ +
+ +
+ {/* Skeleton rows */} +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + + + + +
+ ))} +
+
+ ); + } return (
- {isLoading ? 'Loading events…' : 'No events found'} + No events found
); } @@ -1078,8 +1260,15 @@ export function EventListView({ return (
- {/* Search bar */} -
+ {/* Search bar + sort */} +