feat(bd-40a): Web Timeline Visualization

Add timeline visualization component to the web dashboard:
- Create TimelineView.tsx component showing worker activity over time
- Support time range selection (5m, 10m, 30m, 1h)
- Click on timeline bar to jump to that time in activity stream
- Color-code by worker status (active/idle/error)
- Add timeline toggle button in header
- Integrate with Focus Mode for filtering workers

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-07 04:49:37 +00:00
parent c2123b6f47
commit 296d547c12
4 changed files with 733 additions and 7 deletions

View file

@ -9,6 +9,7 @@ import FileHeatmap from './components/FileHeatmap';
import DependencyDag from './components/DependencyDag';
import RecoveryPanel from './components/RecoveryPanel';
import FileContextPanel from './components/FileContextPanel';
import TimelineView from './components/TimelineView';
const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode';
@ -223,6 +224,8 @@ const App: React.FC = () => {
const [showDependencyDag, setShowDependencyDag] = useState(false);
const [showRecoveryPanel, setShowRecoveryPanel] = useState(false);
const [showFileContext, setShowFileContext] = useState(false);
const [showTimeline, setShowTimeline] = useState(true);
const [selectedTimelineTime, setSelectedTimelineTime] = useState<number | null>(null);
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
// Focus Mode state
@ -354,6 +357,13 @@ const App: React.FC = () => {
});
}, []);
// Timeline time selection handler
const handleTimelineTimeSelect = useCallback((timestamp: number) => {
setSelectedTimelineTime(timestamp);
// Clear the selection after 5 seconds
setTimeout(() => setSelectedTimelineTime(null), 5000);
}, []);
// Filter workers and events based on Focus Mode
const filteredWorkers = focusModeEnabled && pinnedWorkers.size > 0
? workers.filter(w => pinnedWorkers.has(w.id))
@ -418,6 +428,14 @@ const App: React.FC = () => {
<span className="file-context-icon">📄</span>
<span className="file-context-label">Context</span>
</button>
<button
className={`timeline-toggle ${showTimeline ? 'active' : ''}`}
onClick={() => setShowTimeline(!showTimeline)}
title={showTimeline ? 'Hide timeline' : 'Show timeline'}
>
<span className="timeline-toggle-icon">📊</span>
<span className="timeline-toggle-label">Timeline</span>
</button>
{unacknowledgedAlertCount > 0 && (
<button
className="collision-alert-toggle"
@ -466,12 +484,24 @@ const App: React.FC = () => {
focusModeEnabled={focusModeEnabled}
/>
{showTimeline && (
<TimelineView
events={filteredEvents}
workers={filteredWorkers}
onTimeSelect={handleTimelineTimeSelect}
selectedWorker={selectedWorker}
focusModeEnabled={focusModeEnabled}
pinnedWorkers={pinnedWorkers}
/>
)}
<ActivityStream
events={filteredEvents}
selectedWorker={selectedWorker}
pinnedBeads={pinnedBeads}
onTogglePinBead={togglePinBead}
focusModeEnabled={focusModeEnabled}
selectedTimelineTime={selectedTimelineTime}
/>
{selectedWorkerInfo && (

View file

@ -10,6 +10,7 @@ interface ActivityStreamProps {
pinnedBeads?: Set<string>;
onTogglePinBead?: (beadId: string) => void;
focusModeEnabled?: boolean;
selectedTimelineTime?: number | null;
}
const ActivityStream: React.FC<ActivityStreamProps> = ({
@ -20,17 +21,11 @@ const ActivityStream: React.FC<ActivityStreamProps> = ({
pinnedBeads = new Set(),
onTogglePinBead,
focusModeEnabled = false,
selectedTimelineTime,
}) => {
const listRef = useRef<HTMLDivElement>(null);
const [filter, setFilter] = React.useState<ActivityFilter>({});
// Auto-scroll to bottom on new events
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [events]);
// Filter events based on filter criteria
const filteredEvents = useMemo(() => {
return events.filter((event) => {
@ -69,6 +64,44 @@ const ActivityStream: React.FC<ActivityStreamProps> = ({
});
}, [events, filter]);
// Auto-scroll to bottom on new events
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [events]);
// Scroll to events around selected timeline time
useEffect(() => {
if (selectedTimelineTime && listRef.current && filteredEvents.length > 0) {
// Find the first event after the selected time
const targetIndex = filteredEvents.findIndex(
e => new Date(e.timestamp).getTime() >= selectedTimelineTime
);
if (targetIndex !== -1 && listRef.current) {
// Use setTimeout to ensure DOM is updated
const timeoutId = setTimeout(() => {
if (!listRef.current) return;
const eventElements = listRef.current.querySelectorAll('.event-item');
const targetElement = eventElements[targetIndex] as HTMLElement | undefined;
if (targetElement && typeof targetElement.scrollIntoView === 'function') {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add a highlight class temporarily (check classList exists for jsdom compatibility)
if (targetElement.classList && typeof targetElement.classList.add === 'function') {
targetElement.classList.add('timeline-highlight');
setTimeout(() => {
if (targetElement.classList && typeof targetElement.classList.remove === 'function') {
targetElement.classList.remove('timeline-highlight');
}
}, 3000);
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
}
}, [selectedTimelineTime, filteredEvents]);
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour12: false,

View file

@ -0,0 +1,343 @@
import React, { useMemo, useState, useRef, useCallback } from 'react';
import { LogEvent, WorkerInfo } from '../types';
export type TimeRange = '5m' | '10m' | '30m' | '1h';
interface TimelineViewProps {
events: LogEvent[];
workers: WorkerInfo[];
onTimeSelect?: (timestamp: number) => void;
selectedWorker?: string | null;
focusModeEnabled?: boolean;
pinnedWorkers?: Set<string>;
defaultTimeRange?: TimeRange;
}
interface WorkerTimelineData {
workerId: string;
status: 'active' | 'idle' | 'error';
segments: TimelineSegment[];
}
interface TimelineSegment {
start: number;
end: number;
level: 'debug' | 'info' | 'warn' | 'error';
eventCount: number;
}
const TIME_RANGE_MS: Record<TimeRange, number> = {
'5m': 5 * 60 * 1000,
'10m': 10 * 60 * 1000,
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
};
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
'5m': '5 min',
'10m': '10 min',
'30m': '30 min',
'1h': '1 hour',
};
const LEVEL_COLORS: Record<string, string> = {
'debug': 'var(--info)',
'info': 'var(--success)',
'warn': 'var(--warning)',
'error': 'var(--error)',
};
const STATUS_OPACITY: Record<string, number> = {
'active': 1,
'idle': 0.4,
'error': 0.8,
};
const TimelineView: React.FC<TimelineViewProps> = ({
events,
workers,
onTimeSelect,
selectedWorker,
focusModeEnabled = false,
pinnedWorkers = new Set(),
defaultTimeRange = '10m',
}) => {
const [timeRange, setTimeRange] = useState<TimeRange>(defaultTimeRange);
const [hoveredSegment, setHoveredSegment] = useState<{ workerId: string; segment: TimelineSegment } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Filter workers based on focus mode
const filteredWorkers = useMemo(() => {
if (focusModeEnabled && pinnedWorkers.size > 0) {
return workers.filter(w => pinnedWorkers.has(w.id));
}
return workers;
}, [workers, focusModeEnabled, pinnedWorkers]);
// Filter events based on focus mode
const filteredEvents = useMemo(() => {
let filtered = events;
if (focusModeEnabled && pinnedWorkers.size > 0) {
filtered = events.filter(e => pinnedWorkers.has(e.worker));
}
if (selectedWorker) {
filtered = filtered.filter(e => e.worker === selectedWorker);
}
return filtered;
}, [events, focusModeEnabled, pinnedWorkers, selectedWorker]);
// Calculate timeline data
const timelineData = useMemo(() => {
const now = Date.now();
const rangeStart = now - TIME_RANGE_MS[timeRange];
// Create a map of worker activity
const workerMap = new Map<string, WorkerTimelineData>();
// Initialize workers
filteredWorkers.forEach(worker => {
workerMap.set(worker.id, {
workerId: worker.id,
status: worker.status,
segments: [],
});
});
// Also include workers from events that might not be in filteredWorkers
filteredEvents.forEach(event => {
if (!workerMap.has(event.worker)) {
workerMap.set(event.worker, {
workerId: event.worker,
status: 'active',
segments: [],
});
}
});
// Process events into timeline segments
// Group events by worker and time buckets (30 second buckets)
const BUCKET_SIZE = 30 * 1000; // 30 seconds
const workerBuckets = new Map<string, Map<number, { count: number; levels: Map<string, number> }>>();
filteredEvents
.filter(e => new Date(e.timestamp).getTime() >= rangeStart)
.forEach(event => {
const eventTime = new Date(event.timestamp).getTime();
const bucketStart = Math.floor(eventTime / BUCKET_SIZE) * BUCKET_SIZE;
if (!workerBuckets.has(event.worker)) {
workerBuckets.set(event.worker, new Map());
}
const buckets = workerBuckets.get(event.worker)!;
if (!buckets.has(bucketStart)) {
buckets.set(bucketStart, { count: 0, levels: new Map() });
}
const bucket = buckets.get(bucketStart)!;
bucket.count++;
bucket.levels.set(event.level, (bucket.levels.get(event.level) || 0) + 1);
});
// Convert buckets to segments
workerBuckets.forEach((buckets, workerId) => {
const workerData = workerMap.get(workerId);
if (!workerData) return;
buckets.forEach((bucket, bucketStart) => {
// Find the dominant level
let dominantLevel: 'debug' | 'info' | 'warn' | 'error' = 'info';
let maxCount = 0;
bucket.levels.forEach((count, level) => {
if (count > maxCount) {
maxCount = count;
dominantLevel = level as 'debug' | 'info' | 'warn' | 'error';
}
});
workerData.segments.push({
start: bucketStart,
end: bucketStart + BUCKET_SIZE,
level: dominantLevel,
eventCount: bucket.count,
});
});
// Sort segments by time
workerData.segments.sort((a, b) => a.start - b.start);
});
return {
workers: Array.from(workerMap.values()),
rangeStart,
rangeEnd: now,
};
}, [filteredEvents, filteredWorkers, timeRange]);
// Generate time axis labels
const timeLabels = useMemo(() => {
const labels: { time: number; label: string }[] = [];
const now = Date.now();
const rangeMs = TIME_RANGE_MS[timeRange];
// Determine appropriate interval based on range
let interval: number;
if (timeRange === '5m') {
interval = 60 * 1000; // 1 minute
} else if (timeRange === '10m') {
interval = 2 * 60 * 1000; // 2 minutes
} else if (timeRange === '30m') {
interval = 5 * 60 * 1000; // 5 minutes
} else {
interval = 10 * 60 * 1000; // 10 minutes
}
const start = now - rangeMs;
for (let t = Math.ceil(start / interval) * interval; t <= now; t += interval) {
labels.push({
time: t,
label: new Date(t).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
}),
});
}
return labels;
}, [timeRange]);
// Handle click on timeline to select time
const handleTimelineClick = useCallback((e: React.MouseEvent) => {
if (!containerRef.current || !onTimeSelect) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const rangeMs = TIME_RANGE_MS[timeRange];
const clickedTime = timelineData.rangeStart + (percentage * rangeMs);
onTimeSelect(clickedTime);
}, [onTimeSelect, timeRange, timelineData.rangeStart]);
// Truncate worker name for display
const truncateWorker = (worker: string) => {
const parts = worker.split('-');
return parts[parts.length - 1];
};
return (
<div className="timeline-view">
<div className="timeline-header">
<h3>Timeline (last {TIME_RANGE_LABELS[timeRange]})</h3>
<div className="time-range-selector">
{(Object.keys(TIME_RANGE_MS) as TimeRange[]).map(range => (
<button
key={range}
className={`time-range-button ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{TIME_RANGE_LABELS[range]}
</button>
))}
</div>
</div>
<div className="timeline-content" ref={containerRef} onClick={handleTimelineClick}>
{/* Time axis */}
<div className="timeline-axis">
<div className="timeline-worker-label-spacer"></div>
<div className="timeline-time-labels">
{timeLabels.map((label, i) => (
<span
key={i}
className="timeline-time-label"
style={{
position: 'absolute',
left: `${((label.time - timelineData.rangeStart) / TIME_RANGE_MS[timeRange]) * 100}%`,
transform: 'translateX(-50%)',
}}
>
{label.label}
</span>
))}
</div>
</div>
{/* Worker rows */}
<div className="timeline-rows">
{timelineData.workers.length === 0 ? (
<div className="timeline-empty">
No worker activity in this time range
</div>
) : (
timelineData.workers.map(workerData => (
<div key={workerData.workerId} className="timeline-row">
<div className="timeline-worker-label">
<span
className={`worker-status-dot ${workerData.status}`}
title={workerData.status}
></span>
<span className="worker-name">{truncateWorker(workerData.workerId)}</span>
</div>
<div className="timeline-bar-container">
{workerData.segments.map((segment, i) => (
<div
key={i}
className="timeline-segment"
style={{
left: `${((segment.start - timelineData.rangeStart) / TIME_RANGE_MS[timeRange]) * 100}%`,
width: `${((segment.end - segment.start) / TIME_RANGE_MS[timeRange]) * 100}%`,
backgroundColor: LEVEL_COLORS[segment.level],
opacity: STATUS_OPACITY[workerData.status],
}}
onMouseEnter={() => setHoveredSegment({ workerId: workerData.workerId, segment })}
onMouseLeave={() => setHoveredSegment(null)}
title={`${workerData.workerId}: ${segment.eventCount} events at ${new Date(segment.start).toLocaleTimeString()}`}
/>
))}
{/* Hovered segment tooltip */}
{hoveredSegment && hoveredSegment.workerId === workerData.workerId && (
<div
className="timeline-tooltip"
style={{
left: `${((hoveredSegment.segment.start - timelineData.rangeStart) / TIME_RANGE_MS[timeRange]) * 100}%`,
}}
>
<div className="tooltip-time">
{new Date(hoveredSegment.segment.start).toLocaleTimeString()}
</div>
<div className="tooltip-count">
{hoveredSegment.segment.eventCount} events
</div>
<div className={`tooltip-level ${hoveredSegment.segment.level}`}>
{hoveredSegment.segment.level}
</div>
</div>
)}
</div>
</div>
))
)}
</div>
{/* Current time indicator */}
<div
className="timeline-current-time"
style={{
left: '100%',
}}
></div>
</div>
{onTimeSelect && (
<div className="timeline-hint">
Click on timeline to jump to that time in activity stream
</div>
)}
</div>
);
};
export default TimelineView;

View file

@ -3107,3 +3107,323 @@ body {
font-size: 0.75rem;
font-weight: 500;
}
/* ============================================
Timeline View Component Styles
============================================ */
.timeline-view {
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
margin-top: 0.75rem;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--bg-primary);
}
.timeline-header h3 {
font-size: 0.8125rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.time-range-selector {
display: flex;
gap: 0.25rem;
}
.time-range-button {
background: transparent;
border: 1px solid var(--bg-primary);
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.2s;
}
.time-range-button:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.time-range-button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.timeline-content {
position: relative;
padding: 0.5rem 0.75rem;
min-height: 120px;
cursor: crosshair;
}
/* Time axis */
.timeline-axis {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--bg-tertiary);
}
.timeline-worker-label-spacer {
width: 80px;
flex-shrink: 0;
}
.timeline-time-labels {
flex: 1;
position: relative;
height: 16px;
}
.timeline-time-label {
font-size: 0.65rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
}
/* Worker rows */
.timeline-rows {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.timeline-row {
display: flex;
align-items: center;
height: 24px;
}
.timeline-worker-label {
width: 80px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding-right: 0.5rem;
}
.worker-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.worker-status-dot.active {
background: var(--success);
box-shadow: 0 0 4px var(--success);
}
.worker-status-dot.idle {
background: var(--text-secondary);
}
.worker-status-dot.error {
background: var(--error);
box-shadow: 0 0 4px var(--error);
}
.timeline-worker-label .worker-name {
font-size: 0.7rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-bar-container {
flex: 1;
position: relative;
height: 16px;
background: var(--bg-primary);
border-radius: 2px;
overflow: visible;
}
/* Timeline segments (activity bars) */
.timeline-segment {
position: absolute;
top: 2px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: all 0.15s;
min-width: 2px;
}
.timeline-segment:hover {
transform: scaleY(1.2);
z-index: 10;
}
/* Timeline tooltip */
.timeline-tooltip {
position: absolute;
top: -40px;
background: var(--bg-primary);
border: 1px solid var(--bg-tertiary);
border-radius: 4px;
padding: 0.375rem 0.5rem;
font-size: 0.7rem;
z-index: 100;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.tooltip-time {
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-primary);
font-weight: 600;
}
.tooltip-count {
color: var(--text-secondary);
}
.tooltip-level {
text-transform: uppercase;
font-weight: 600;
}
.tooltip-level.debug { color: var(--info); }
.tooltip-level.info { color: var(--success); }
.tooltip-level.warn { color: var(--warning); }
.tooltip-level.error { color: var(--error); }
/* Current time indicator */
.timeline-current-time {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent);
opacity: 0.8;
pointer-events: none;
}
.timeline-current-time::before {
content: '';
position: absolute;
top: -4px;
left: -3px;
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
}
/* Empty state */
.timeline-empty {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
color: var(--text-secondary);
font-size: 0.8125rem;
}
/* Hint text */
.timeline-hint {
padding: 0.375rem 0.75rem;
font-size: 0.7rem;
color: var(--text-secondary);
text-align: center;
background: var(--bg-primary);
border-top: 1px solid var(--bg-tertiary);
}
/* Timeline toggle button */
.timeline-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.timeline-toggle:hover {
background: rgba(255, 255, 255, 0.15);
}
.timeline-toggle-icon {
font-size: 0.875rem;
}
.timeline-toggle-label {
font-size: 0.75rem;
font-weight: 500;
}
/* Responsive adjustments for Timeline */
@media (max-width: 768px) {
.timeline-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.time-range-selector {
width: 100%;
justify-content: space-between;
}
.time-range-button {
flex: 1;
text-align: center;
}
.timeline-worker-label-spacer,
.timeline-worker-label {
width: 60px;
}
.timeline-worker-label .worker-name {
font-size: 0.65rem;
}
}
/* Timeline highlight animation for activity stream */
.event-item.timeline-highlight {
animation: timeline-highlight-pulse 3s ease-out;
background: rgba(233, 69, 96, 0.2);
border-left: 3px solid var(--accent);
}
@keyframes timeline-highlight-pulse {
0% {
background: rgba(233, 69, 96, 0.4);
}
100% {
background: transparent;
}
}
/* Timeline toggle button active state */
.timeline-toggle.active {
background: rgba(233, 69, 96, 0.3);
border-color: var(--accent);
color: var(--accent);
}