From 78fe6d18a176a7d59ebdea517370acbc556b3a18 Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 27 Apr 2026 06:30:09 -0400 Subject: [PATCH] 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 --- .../frontend/src/components/TimelineView.tsx | 309 +++++++++++--- src/web/frontend/src/index.css | 386 +++++++++++++++++- 2 files changed, 631 insertions(+), 64 deletions(-) diff --git a/src/web/frontend/src/components/TimelineView.tsx b/src/web/frontend/src/components/TimelineView.tsx index 2f6a256..0753056 100644 --- a/src/web/frontend/src/components/TimelineView.tsx +++ b/src/web/frontend/src/components/TimelineView.tsx @@ -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; 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 = { @@ -57,14 +65,63 @@ const TimelineView: React.FC = ({ events, workers, onTimeSelect, + onWorkerClick, selectedWorker, focusModeEnabled = false, pinnedWorkers = new Set(), defaultTimeRange = '10m', + currentTime: propCurrentTime, + timelineStyle = 'blocks', + compactMode = false, }) => { const [timeRange, setTimeRange] = useState(defaultTimeRange); + const [style, setStyle] = useState(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(Date.now()); + const [newEventHighlights, setNewEventHighlights] = useState>(new Set()); const containerRef = useRef(null); + const intervalRef = useRef | 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(); + 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 = ({ // 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 = ({ workerId: worker.id, status: worker.status, segments: [], + totalEvents: 0, + isActive: worker.status === 'active', }); }); @@ -110,6 +169,8 @@ const TimelineView: React.FC = ({ workerId: event.worker, status: 'active', segments: [], + totalEvents: 0, + isActive: true, }); } }); @@ -144,7 +205,11 @@ const TimelineView: React.FC = ({ 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 = ({ } }); + // 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 = ({ // 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 = ({ }, [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 ( -
+

Timeline (last {TIME_RANGE_LABELS[timeRange]})

-
- {(Object.keys(TIME_RANGE_MS) as TimeRange[]).map(range => ( - - ))} +
+ +
+ {(Object.keys(TIME_RANGE_MS) as TimeRange[]).map(range => ( + + ))} +
@@ -271,54 +414,92 @@ const TimelineView: React.FC = ({ No worker activity in this time range
) : ( - timelineData.workers.map(workerData => ( -
-
- - {truncateWorker(workerData.workerId)} -
-
- {workerData.segments.map((segment, i) => ( -
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 ( +
{ + e.stopPropagation(); + handleWorkerClick(workerData.workerId); + }} + > +
+ + + {truncateWorker(workerData.workerId)} + + {workerData.totalEvents > 0 && ( + ({workerData.totalEvents}) + )} +
+
+ {style === 'blocks' ? ( +
+ + {blocks.map((block, i) => ( + + {block.char} + + ))} + +
+ ) : ( + <> + {workerData.segments.map((segment, i) => ( +
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 && ( -
-
- {new Date(hoveredSegment.segment.start).toLocaleTimeString()} -
-
- {hoveredSegment.segment.eventCount} events -
-
- {hoveredSegment.segment.level} -
-
- )} + {/* Hovered segment tooltip */} + {hoveredSegment && hoveredSegment.workerId === workerData.workerId && ( +
+
+ {new Date(hoveredSegment.segment.start).toLocaleTimeString()} +
+
+ {hoveredSegment.segment.eventCount} events +
+
+ {hoveredSegment.segment.level} +
+
+ )} + + )} +
-
- )) + ); + }) )}
@@ -328,12 +509,14 @@ const TimelineView: React.FC = ({ style={{ left: '100%', }} - >
+ > + +
{onTimeSelect && (
- 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'}
)}
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 8454157..5d1bf33 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -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 */