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:
parent
c2123b6f47
commit
296d547c12
4 changed files with 733 additions and 7 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
343
src/web/frontend/src/components/TimelineView.tsx
Normal file
343
src/web/frontend/src/components/TimelineView.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue