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