diff --git a/src/design-system/composites/EventFeed/EventFeed.module.css b/src/design-system/composites/EventFeed/EventFeed.module.css new file mode 100644 index 0000000..a7a5e86 --- /dev/null +++ b/src/design-system/composites/EventFeed/EventFeed.module.css @@ -0,0 +1,108 @@ +.feed { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.clearBtn { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: color 0.1s; +} + +.clearBtn:hover { + color: var(--text-primary); +} + +.list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 12px; + transition: background 0.08s; + border-bottom: 1px solid var(--border-subtle); +} + +.item:last-child { + border-bottom: none; +} + +.item:hover { + background: var(--bg-hover); +} + +.dot { + margin-top: 3px; + flex-shrink: 0; +} + +.message { + flex: 1; + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +/* Severity tint */ +.error .message { + color: var(--error); +} + +.warning .message { + color: var(--warning); +} + +.time { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-faint); + flex-shrink: 0; + margin-top: 2px; +} + +.empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 12px; +} + +.resumeBtn { + flex-shrink: 0; + padding: 6px; + background: var(--amber-bg); + border: 1px solid var(--amber-light); + border-radius: 0; + color: var(--amber-deep); + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + text-align: center; + transition: background 0.1s; +} + +.resumeBtn:hover { + background: var(--amber-light); +} diff --git a/src/design-system/composites/EventFeed/EventFeed.tsx b/src/design-system/composites/EventFeed/EventFeed.tsx new file mode 100644 index 0000000..0d19821 --- /dev/null +++ b/src/design-system/composites/EventFeed/EventFeed.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import styles from './EventFeed.module.css' +import { StatusDot } from '../../primitives/StatusDot/StatusDot' +import { FilterPill } from '../../primitives/FilterPill/FilterPill' + +export interface FeedEvent { + id: string + severity: 'error' | 'warning' | 'success' | 'running' + message: string + timestamp: Date +} + +type SeverityFilter = 'error' | 'warning' | 'success' | 'running' + +interface EventFeedProps { + events: FeedEvent[] + maxItems?: number + className?: string +} + +function formatRelativeTime(date: Date): string { + const now = Date.now() + const diff = now - date.getTime() + if (diff < 1000) return 'just now' + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago` + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` + return date.toLocaleDateString() +} + +const SEVERITY_LABELS: Record = { + error: 'Error', + warning: 'Warning', + success: 'Success', + running: 'Info', +} + +export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) { + const scrollRef = useRef(null) + const [isPaused, setIsPaused] = useState(false) + const [activeFilters, setActiveFilters] = useState>(new Set()) + + const displayed = events + .slice(-maxItems) + .filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity)) + + // Auto-scroll to bottom + const scrollToBottom = useCallback(() => { + const el = scrollRef.current + if (el) { + el.scrollTop = el.scrollHeight + } + }, []) + + useEffect(() => { + if (!isPaused) { + scrollToBottom() + } + }, [events, isPaused, scrollToBottom]) + + function handleScroll() { + const el = scrollRef.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8 + setIsPaused(!atBottom) + } + + function toggleFilter(severity: SeverityFilter) { + setActiveFilters((prev) => { + const next = new Set(prev) + if (next.has(severity)) { + next.delete(severity) + } else { + next.add(severity) + } + return next + }) + } + + const allSeverities: SeverityFilter[] = ['error', 'warning', 'success', 'running'] + + return ( +
+ {/* Filter pills */} +
+ {allSeverities.map((sev) => { + const count = events.filter((e) => e.severity === sev).length + return ( + toggleFilter(sev)} + /> + ) + })} + {activeFilters.size > 0 && ( + + )} +
+ + {/* Event list */} +
+ {displayed.length === 0 ? ( +
No events
+ ) : ( + displayed.map((event) => ( +
+ + {event.message} + {formatRelativeTime(event.timestamp)} +
+ )) + )} +
+ + {/* Pause indicator */} + {isPaused && ( + + )} +
+ ) +} diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css new file mode 100644 index 0000000..b1e0af4 --- /dev/null +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css @@ -0,0 +1,97 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 5px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.row.clickable { + cursor: pointer; + border-radius: var(--radius-sm); + padding: 2px 0; + transition: background 0.1s; +} + +.row.clickable:hover { + background: var(--bg-hover); +} + +.row.clickable:focus-visible { + outline: 2px solid var(--amber); + outline-offset: 2px; +} + +.name { + width: 120px; + text-align: right; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} + +.barBg { + flex: 1; + height: 16px; + background: var(--bg-inset); + border-radius: 3px; + position: relative; + overflow: hidden; +} + +.barFill { + position: absolute; + top: 0; + height: 100%; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 5px; + min-width: 8px; + transition: opacity 0.1s; +} + +.barLabel { + font-family: var(--font-mono); + font-size: 8px; + color: white; + font-weight: 500; +} + +.ok { + background: rgba(61, 124, 71, 0.5); +} + +.slow { + background: rgba(194, 117, 22, 0.5); +} + +.fail { + background: rgba(192, 57, 43, 0.5); +} + +.dur { + width: 42px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; + text-align: right; +} + +.empty { + color: var(--text-muted); + font-size: 12px; + padding: 16px; + text-align: center; +} diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx new file mode 100644 index 0000000..b1f58aa --- /dev/null +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx @@ -0,0 +1,86 @@ +import styles from './ProcessorTimeline.module.css' + +export interface ProcessorStep { + name: string + type: string + durationMs: number + status: 'ok' | 'slow' | 'fail' + startMs: number +} + +interface ProcessorTimelineProps { + processors: ProcessorStep[] + totalMs: number + onProcessorClick?: (processor: ProcessorStep) => void + className?: string +} + +function formatDuration(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s` + return `${ms}ms` +} + +export function ProcessorTimeline({ + processors, + totalMs, + onProcessorClick, + className, +}: ProcessorTimelineProps) { + const safeTotal = totalMs || 1 + + if (processors.length === 0) { + return ( +
No processor data
+ ) + } + + return ( +
+ {processors.map((proc, i) => { + const leftPct = (proc.startMs / safeTotal) * 100 + const widthPct = Math.max((proc.durationMs / safeTotal) * 100, 1) + + const barClass = [ + styles.barFill, + proc.status === 'ok' ? styles.ok : '', + proc.status === 'slow' ? styles.slow : '', + proc.status === 'fail' ? styles.fail : '', + ] + .filter(Boolean) + .join(' ') + + return ( +
onProcessorClick?.(proc)} + role={onProcessorClick ? 'button' : undefined} + tabIndex={onProcessorClick ? 0 : undefined} + onKeyDown={(e) => { + if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onProcessorClick(proc) + } + }} + aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`} + > +
+ {proc.name} +
+
+
+ {widthPct > 8 && ( + {formatDuration(proc.durationMs)} + )} +
+
+
{formatDuration(proc.durationMs)}
+
+ ) + })} +
+ ) +}