feat: ProcessorTimeline and EventFeed composites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:58:09 +01:00
parent f88e83dd0a
commit e86fecbd00
4 changed files with 434 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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<SeverityFilter, string> = {
error: 'Error',
warning: 'Warning',
success: 'Success',
running: 'Info',
}
export function EventFeed({ events, maxItems = 200, className }: EventFeedProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [isPaused, setIsPaused] = useState(false)
const [activeFilters, setActiveFilters] = useState<Set<SeverityFilter>>(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 (
<div className={`${styles.feed} ${className ?? ''}`}>
{/* Filter pills */}
<div className={styles.filters}>
{allSeverities.map((sev) => {
const count = events.filter((e) => e.severity === sev).length
return (
<FilterPill
key={sev}
label={SEVERITY_LABELS[sev]}
count={count}
active={activeFilters.has(sev)}
onClick={() => toggleFilter(sev)}
/>
)
})}
{activeFilters.size > 0 && (
<button
className={styles.clearBtn}
onClick={() => setActiveFilters(new Set())}
>
Clear
</button>
)}
</div>
{/* Event list */}
<div
ref={scrollRef}
className={styles.list}
onScroll={handleScroll}
aria-live="polite"
aria-label="Event feed"
>
{displayed.length === 0 ? (
<div className={styles.empty}>No events</div>
) : (
displayed.map((event) => (
<div key={event.id} className={`${styles.item} ${styles[event.severity]}`}>
<StatusDot variant={event.severity} className={styles.dot} />
<span className={styles.message}>{event.message}</span>
<span className={styles.time}>{formatRelativeTime(event.timestamp)}</span>
</div>
))
)}
</div>
{/* Pause indicator */}
{isPaused && (
<button
className={styles.resumeBtn}
onClick={() => {
setIsPaused(false)
scrollToBottom()
}}
>
Resume auto-scroll
</button>
)}
</div>
)
}

View File

@@ -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;
}

View File

@@ -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 (
<div className={`${styles.empty} ${className ?? ''}`}>No processor data</div>
)
}
return (
<div className={`${styles.timeline} ${className ?? ''}`}>
{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 (
<div
key={i}
className={`${styles.row} ${onProcessorClick ? styles.clickable : ''}`}
onClick={() => 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})`}
>
<div className={styles.name} title={proc.name}>
{proc.name}
</div>
<div className={styles.barBg}>
<div
className={barClass}
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
>
{widthPct > 8 && (
<span className={styles.barLabel}>{formatDuration(proc.durationMs)}</span>
)}
</div>
</div>
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
</div>
)
})}
</div>
)
}