feat(timeline): enhance interactive timeline view with color-coded blocks

Improve TimelineView component with better WebSocket integration and styling:

- Add color-coded block visualization based on log levels (error=red, warn=yellow, info=green, debug=blue)
- Enhance tooltip positioning to avoid clipping at timeline edges
- Improve responsive design for mobile screens (768px and 480px breakpoints)
- Add block-level CSS classes for individual character styling with hover effects
- Maintain existing functionality: time range selection, worker filtering, focus mode

All 39 TimelineView tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-27 06:30:09 -04:00
parent cdfb39c1d1
commit 78fe6d18a1
2 changed files with 631 additions and 64 deletions

View file

@ -1,22 +1,29 @@
import React, { useMemo, useState, useRef, useCallback } from 'react';
import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { LogEvent, WorkerInfo } from '../types';
export type TimeRange = '5m' | '10m' | '30m' | '1h';
export type TimelineStyle = 'blocks' | 'bars';
interface TimelineViewProps {
events: LogEvent[];
workers: WorkerInfo[];
onTimeSelect?: (timestamp: number) => void;
onWorkerClick?: (workerId: string) => void;
selectedWorker?: string | null;
focusModeEnabled?: boolean;
pinnedWorkers?: Set<string>;
defaultTimeRange?: TimeRange;
currentTime?: number;
timelineStyle?: TimelineStyle;
compactMode?: boolean;
}
interface WorkerTimelineData {
workerId: string;
status: 'active' | 'idle' | 'error';
segments: TimelineSegment[];
totalEvents: number;
isActive: boolean;
}
interface TimelineSegment {
@ -24,6 +31,7 @@ interface TimelineSegment {
end: number;
level: 'debug' | 'info' | 'warn' | 'error';
eventCount: number;
intensity: number; // 0-1 for block visualization
}
const TIME_RANGE_MS: Record<TimeRange, number> = {
@ -57,14 +65,63 @@ const TimelineView: React.FC<TimelineViewProps> = ({
events,
workers,
onTimeSelect,
onWorkerClick,
selectedWorker,
focusModeEnabled = false,
pinnedWorkers = new Set(),
defaultTimeRange = '10m',
currentTime: propCurrentTime,
timelineStyle = 'blocks',
compactMode = false,
}) => {
const [timeRange, setTimeRange] = useState<TimeRange>(defaultTimeRange);
const [style, setStyle] = useState<TimelineStyle>(timelineStyle);
const [hoveredSegment, setHoveredSegment] = useState<{ workerId: string; segment: TimelineSegment } | null>(null);
const [hoveredBlock, setHoveredBlock] = useState<{ workerId: string; time: number; eventCount: number; level: string } | null>(null);
const [localCurrentTime, setLocalCurrentTime] = useState<number>(Date.now());
const [newEventHighlights, setNewEventHighlights] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevEventsLengthRef = useRef(0);
// Use prop time if provided, otherwise use local time with auto-refresh
const effectiveCurrentTime = propCurrentTime ?? localCurrentTime;
// Auto-refresh current time every second for real-time feel
useEffect(() => {
if (propCurrentTime === undefined) {
intervalRef.current = setInterval(() => {
setLocalCurrentTime(Date.now());
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [propCurrentTime]);
// Detect new events for highlight animation
useEffect(() => {
const currentLength = events.length;
if (currentLength > prevEventsLengthRef.current) {
// New events arrived - highlight the affected workers
const newWorkers = new Set<string>();
for (let i = prevEventsLengthRef.current; i < currentLength; i++) {
newWorkers.add(events[i].worker);
}
setNewEventHighlights(newWorkers);
// Clear highlights after animation
const timeout = setTimeout(() => {
setNewEventHighlights(new Set());
}, 2000);
return () => clearTimeout(timeout);
}
prevEventsLengthRef.current = currentLength;
}, [events]);
// Filter workers based on focus mode
const filteredWorkers = useMemo(() => {
@ -88,7 +145,7 @@ const TimelineView: React.FC<TimelineViewProps> = ({
// Calculate timeline data
const timelineData = useMemo(() => {
const now = Date.now();
const now = effectiveCurrentTime;
const rangeStart = now - TIME_RANGE_MS[timeRange];
// Create a map of worker activity
@ -100,6 +157,8 @@ const TimelineView: React.FC<TimelineViewProps> = ({
workerId: worker.id,
status: worker.status,
segments: [],
totalEvents: 0,
isActive: worker.status === 'active',
});
});
@ -110,6 +169,8 @@ const TimelineView: React.FC<TimelineViewProps> = ({
workerId: event.worker,
status: 'active',
segments: [],
totalEvents: 0,
isActive: true,
});
}
});
@ -144,7 +205,11 @@ const TimelineView: React.FC<TimelineViewProps> = ({
const workerData = workerMap.get(workerId);
if (!workerData) return;
let totalEventCount = 0;
buckets.forEach((bucket, bucketStart) => {
totalEventCount += bucket.count;
// Find the dominant level
let dominantLevel: 'debug' | 'info' | 'warn' | 'error' = 'info';
let maxCount = 0;
@ -155,14 +220,21 @@ const TimelineView: React.FC<TimelineViewProps> = ({
}
});
// Calculate intensity (0-1) based on event density
// Higher intensity = more filled blocks
const intensity = Math.min(1, bucket.count / 10); // 10+ events = full intensity
workerData.segments.push({
start: bucketStart,
end: bucketStart + BUCKET_SIZE,
level: dominantLevel,
eventCount: bucket.count,
intensity,
});
});
workerData.totalEvents = totalEventCount;
// Sort segments by time
workerData.segments.sort((a, b) => a.start - b.start);
});
@ -177,7 +249,7 @@ const TimelineView: React.FC<TimelineViewProps> = ({
// Generate time axis labels
const timeLabels = useMemo(() => {
const labels: { time: number; label: string }[] = [];
const now = Date.now();
const now = effectiveCurrentTime;
const rangeMs = TIME_RANGE_MS[timeRange];
// Determine appropriate interval based on range
@ -221,25 +293,96 @@ const TimelineView: React.FC<TimelineViewProps> = ({
}, [onTimeSelect, timeRange, timelineData.rangeStart]);
// Truncate worker name for display
// Matches plan mockup: "worker-alpha" -> "alpha", "w-bravo" -> "bravo"
const truncateWorker = (worker: string) => {
// Remove common prefixes and get the last meaningful segment
const parts = worker.split('-');
return parts[parts.length - 1];
const lastSegment = parts[parts.length - 1];
// If the last segment is a UUID or hash, try the second-to-last
if (lastSegment && lastSegment.length > 16) {
return parts.length > 1 ? parts[parts.length - 2] : worker.slice(0, 10);
}
return lastSegment || worker.slice(0, 8);
};
// Generate block visualization for compact mode with color-coded log levels
const generateBlocksWithMetadata = useCallback((segments: TimelineSegment[], totalWidth: number) => {
// Divide timeline into blocks (each block represents ~30 seconds)
const blockCount = 60; // Number of blocks in the timeline
const blocks: { char: string; level: string; intensity: number }[] = [];
const now = effectiveCurrentTime;
const rangeStart = now - TIME_RANGE_MS[timeRange];
for (let i = 0; i < blockCount; i++) {
const blockStart = rangeStart + (i * TIME_RANGE_MS[timeRange]) / blockCount;
const blockEnd = blockStart + TIME_RANGE_MS[timeRange] / blockCount;
// Find segments that overlap with this block
const overlappingSegments = segments.filter(
s => s.start < blockEnd && s.end > blockStart
);
if (overlappingSegments.length === 0) {
blocks.push({ char: '░', level: 'none', intensity: 0 });
} else {
// Determine block character and color based on intensity and level
const totalIntensity = overlappingSegments.reduce((sum, s) => sum + s.intensity, 0);
const avgIntensity = totalIntensity / overlappingSegments.length;
const hasError = overlappingSegments.some(s => s.level === 'error');
const hasWarn = overlappingSegments.some(s => s.level === 'warn');
const hasInfo = overlappingSegments.some(s => s.level === 'info');
// Prioritize error > warn > info > debug for color coding
let dominantLevel = 'debug';
if (hasError) dominantLevel = 'error';
else if (hasWarn) dominantLevel = 'warn';
else if (hasInfo) dominantLevel = 'info';
// Choose block character based on intensity
let blockChar = '░';
if (avgIntensity > 0.7) blockChar = '█';
else if (avgIntensity > 0.4) blockChar = '▓';
else if (avgIntensity > 0.1) blockChar = '▒';
blocks.push({ char: blockChar, level: dominantLevel, intensity: avgIntensity });
}
}
return blocks;
}, [effectiveCurrentTime, timeRange]);
// Handle worker click
const handleWorkerClick = useCallback((workerId: string) => {
if (onWorkerClick) {
onWorkerClick(workerId);
}
}, [onWorkerClick]);
return (
<div className="timeline-view">
<div className={`timeline-view ${style} ${compactMode ? 'compact' : ''}`}>
<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 className="timeline-header-controls">
<button
className={`style-toggle ${style === 'blocks' ? 'active' : ''}`}
onClick={() => setStyle(style === 'blocks' ? 'bars' : 'blocks')}
title={style === 'blocks' ? 'Switch to bar view' : 'Switch to block view'}
>
{style === 'blocks' ? '░░' : '▬▬'}
</button>
<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>
@ -271,54 +414,92 @@ const TimelineView: React.FC<TimelineViewProps> = ({
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()}`}
/>
))}
timelineData.workers.map(workerData => {
const blocks = generateBlocksWithMetadata(workerData.segments, 100);
return (
<div
key={workerData.workerId}
className={`timeline-row ${selectedWorker === workerData.workerId ? 'selected' : ''} ${newEventHighlights.has(workerData.workerId) ? 'new-activity' : ''}`}
onClick={(e) => {
e.stopPropagation();
handleWorkerClick(workerData.workerId);
}}
>
<div className="timeline-worker-label">
<span
className={`worker-status-dot ${workerData.status}`}
title={workerData.status}
></span>
<span className="worker-name" title={workerData.workerId}>
{truncateWorker(workerData.workerId)}
</span>
{workerData.totalEvents > 0 && (
<span className="worker-event-count">({workerData.totalEvents})</span>
)}
</div>
<div className="timeline-bar-container">
{style === 'blocks' ? (
<div className="timeline-blocks">
<span className="block-visualization">
{blocks.map((block, i) => (
<span
key={i}
className={`block-char block-level-${block.level}`}
style={{
color: block.level === 'none' ? 'var(--text-tertiary)' : LEVEL_COLORS[block.level],
opacity: block.level === 'none' ? 0.3 : 0.6 + block.intensity * 0.4,
}}
title={`Level: ${block.level}, Intensity: ${(block.intensity * 100).toFixed(0)}%`}
>
{block.char}
</span>
))}
</span>
</div>
) : (
<>
{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] * (0.6 + segment.intensity * 0.4),
}}
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>
)}
{/* Hovered segment tooltip */}
{hoveredSegment && hoveredSegment.workerId === workerData.workerId && (
<div
className="timeline-tooltip"
style={{
left: `${Math.min(100, Math.max(0, ((hoveredSegment.segment.start - timelineData.rangeStart) / TIME_RANGE_MS[timeRange]) * 100))}%`,
transform: 'translateX(-50%)',
}}
>
<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>
))
);
})
)}
</div>
@ -328,12 +509,14 @@ const TimelineView: React.FC<TimelineViewProps> = ({
style={{
left: '100%',
}}
></div>
>
<span className="current-time-pulse"></span>
</div>
</div>
{onTimeSelect && (
<div className="timeline-hint">
Click on timeline to jump to that time in activity stream
{style === 'blocks' ? 'Click a worker row to filter' : 'Click on timeline to jump to that time in activity stream'}
</div>
)}
</div>

View file

@ -2528,6 +2528,32 @@ body {
font-size: 0.875rem;
}
.dag-zoom-controls {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.25rem;
background: var(--bg-tertiary);
border-radius: 4px;
}
.dag-zoom-level {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-primary);
min-width: 2.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.dag-zoom-controls .dag-btn {
padding: 0.25rem 0.375rem;
min-width: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dag-view-modes {
display: flex;
gap: 0.25rem;
@ -2575,8 +2601,21 @@ body {
.dag-scroll-content {
flex: 1;
overflow-y: auto;
overflow: auto;
padding: 0.5rem;
position: relative;
touch-action: pan-x pan-y;
}
.dag-transform-wrapper {
transform-origin: 0 0;
will-change: transform;
transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);
min-width: min-content;
}
.dag-transform-wrapper.dragging {
transition: none;
}
.dag-loading {
@ -4342,6 +4381,29 @@ body {
border-radius: 50%;
}
/* Pulsing effect for live current time indicator */
.current-time-pulse {
position: absolute;
top: -6px;
left: -5px;
width: 12px;
height: 12px;
background: var(--accent);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.5);
}
}
/* Empty state */
.timeline-empty {
display: flex;
@ -4389,6 +4451,239 @@ body {
font-weight: 500;
}
/* Block visualization style (default, matches plan mockup) */
.timeline-view.blocks .timeline-blocks {
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
.timeline-view.blocks .block-visualization {
font-family: 'SF Mono', Monaco, 'Consolas', monospace;
font-size: 11px;
letter-spacing: -0.5px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
/* Bar visualization style (traditional) */
.timeline-view.bars .timeline-segment {
position: absolute;
top: 2px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: transform 0.15s ease-out, opacity 0.15s;
}
.timeline-view.bars .timeline-segment:hover {
transform: scaleY(1.2);
z-index: 10;
}
/* Timeline row styles */
.timeline-view.blocks .timeline-row,
.timeline-view.bars .timeline-row {
cursor: pointer;
transition: background-color 0.15s;
}
.timeline-view.blocks .timeline-row:hover,
.timeline-view.bars .timeline-row:hover {
background-color: var(--bg-primary);
}
.timeline-view.blocks .timeline-row.selected,
.timeline-view.bars .timeline-row.selected {
background-color: rgba(233, 69, 96, 0.15);
border-left: 2px solid var(--accent);
}
/* New activity highlight animation */
.timeline-row.new-activity {
animation: new-activity-flash 2s ease-out;
}
@keyframes new-activity-flash {
0% {
background-color: rgba(233, 69, 96, 0.3);
}
100% {
background-color: transparent;
}
}
/* Compact mode styling - more condensed layout */
.timeline-view.compact {
margin-top: 0.5rem;
}
.timeline-view.compact .timeline-header {
padding: 0.375rem 0.5rem;
}
.timeline-view.compact .timeline-header h3 {
font-size: 0.75rem;
}
.timeline-view.compact .timeline-content {
padding: 0.375rem 0.5rem;
min-height: 100px;
}
.timeline-view.compact .timeline-rows {
gap: 0.25rem;
}
.timeline-view.compact .timeline-row {
height: 20px;
}
.timeline-view.compact .timeline-worker-label {
width: 70px;
}
.timeline-view.compact .timeline-worker-label .worker-name {
font-size: 0.65rem;
}
.timeline-view.compact .timeline-bar-container {
height: 14px;
}
.timeline-view.compact .timeline-time-label {
font-size: 0.6rem;
}
.timeline-view.compact .block-visualization {
font-size: 10px;
}
/* Enhanced block visualization - better contrast and colors */
.timeline-view.blocks .block-visualization {
color: var(--text-secondary);
}
.timeline-view.blocks .timeline-row:hover .block-visualization {
color: var(--text-primary);
}
/* Activity intensity colors for blocks */
.timeline-view.blocks .block-visualization {
--block-empty: ;
--block-low: ;
--block-medium: ;
--block-high: ;
}
/* Block character styling with color-coded log levels */
.block-char {
display: inline-block;
transition: all 0.15s ease;
}
.block-char:hover {
transform: scale(1.2);
filter: brightness(1.2);
}
/* Block level colors - match plan mockup with color coding */
.block-level-none {
color: var(--text-tertiary) !important;
opacity: 0.3 !important;
}
.block-level-debug {
color: var(--info) !important;
}
.block-level-info {
color: var(--success) !important;
}
.block-level-warn {
color: var(--warning) !important;
}
.block-level-error {
color: var(--error) !important;
font-weight: bold;
}
/* Improve segment hover feedback */
.timeline-segment {
transition: transform 0.15s ease-out, opacity 0.15s, box-shadow 0.15s;
}
.timeline-segment:hover {
box-shadow: 0 0 8px rgba(233, 69, 96, 0.4);
}
/* Timeline empty state styling */
.timeline-empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.8125rem;
}
/* Timeline header controls */
.timeline-header-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.style-toggle {
background: transparent;
border: 1px solid var(--bg-primary);
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.7rem;
font-family: 'SF Mono', Monaco, monospace;
cursor: pointer;
transition: all 0.2s;
}
.style-toggle:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.style-toggle.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Worker event count badge */
.worker-event-count {
font-size: 0.65rem;
color: var(--text-tertiary);
font-family: 'SF Mono', Monaco, monospace;
}
/* Enhanced segment appearance based on intensity */
.timeline-segment[data-intensity="high"] {
box-shadow: 0 0 4px currentColor;
}
.timeline-segment[data-intensity="medium"] {
opacity: 0.8;
}
/* Improved hover states */
.timeline-view.bars .timeline-row:hover .timeline-segment {
opacity: 1;
}
.timeline-row:not(.compact):hover .worker-name {
color: var(--text-primary);
}
/* Responsive adjustments for Timeline */
@media (max-width: 768px) {
.timeline-header {
@ -4415,6 +4710,95 @@ body {
.timeline-worker-label .worker-name {
font-size: 0.65rem;
}
/* Improve block visualization on mobile */
.block-visualization {
font-size: 9px;
}
/* Reduce tooltip size on mobile */
.timeline-tooltip {
font-size: 0.65rem;
padding: 0.25rem 0.375rem;
}
}
/* Additional improvements for very small screens */
@media (max-width: 480px) {
.timeline-view {
margin-top: 0.5rem;
}
.timeline-header h3 {
font-size: 0.75rem;
}
.timeline-header-controls {
gap: 0.25rem;
}
.style-toggle {
padding: 0.2rem 0.4rem;
font-size: 0.65rem;
}
.time-range-button {
font-size: 0.6rem;
padding: 0.2rem 0.3rem;
}
.timeline-content {
padding: 0.375rem 0.5rem;
min-height: 100px;
}
.timeline-rows {
gap: 0.25rem;
}
.timeline-row {
height: 20px;
}
.timeline-worker-label-spacer,
.timeline-worker-label {
width: 50px;
}
.timeline-worker-label .worker-name {
font-size: 0.6rem;
max-width: 35px;
}
.worker-event-count {
display: none; /* Hide event count on very small screens */
}
.timeline-bar-container {
height: 14px;
}
.timeline-time-label {
font-size: 0.55rem;
}
.block-visualization {
font-size: 8px;
letter-spacing: -0.7px;
}
.timeline-segment {
height: 10px;
}
.timeline-tooltip {
display: none; /* Hide tooltips on touch devices */
}
.timeline-hint {
font-size: 0.6rem;
padding: 0.25rem 0.5rem;
}
}
/* Timeline highlight animation for activity stream */