feat: ProcessorTimeline and EventFeed composites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
src/design-system/composites/EventFeed/EventFeed.module.css
Normal file
108
src/design-system/composites/EventFeed/EventFeed.module.css
Normal 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);
|
||||||
|
}
|
||||||
143
src/design-system/composites/EventFeed/EventFeed.tsx
Normal file
143
src/design-system/composites/EventFeed/EventFeed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user