diff --git a/static/css/components/signal-timeline.css b/static/css/components/signal-timeline.css index 60ff7c0..aee831a 100644 --- a/static/css/components/signal-timeline.css +++ b/static/css/components/signal-timeline.css @@ -11,17 +11,63 @@ background: var(--bg-card, #1a1a1a); border: 1px solid var(--border-color, #333); border-radius: 6px; - padding: 12px; font-family: 'JetBrains Mono', monospace; } +.signal-timeline.collapsed .signal-timeline-body { + display: none; +} + +.signal-timeline.collapsed .signal-timeline-header { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + .signal-timeline-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color, #333); + padding: 10px 12px; + cursor: pointer; + user-select: none; +} + +.signal-timeline-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.signal-timeline-body { + padding: 0 12px 12px 12px; + border-top: 1px solid var(--border-color, #333); +} + +.signal-timeline-collapse-icon { + margin-right: 8px; + font-size: 10px; + transition: transform 0.2s ease; +} + +.signal-timeline.collapsed .signal-timeline-collapse-icon { + transform: rotate(-90deg); +} + +.signal-timeline-header-stats { + display: flex; + gap: 12px; + font-size: 10px; + color: var(--text-dim, #666); +} + +.signal-timeline-header-stat { + display: flex; + align-items: center; + gap: 4px; +} + +.signal-timeline-header-stat .stat-value { + color: var(--text-primary, #fff); + font-weight: 500; } .signal-timeline-title { @@ -113,8 +159,27 @@ display: flex; flex-direction: column; gap: 2px; - max-height: 300px; + max-height: 180px; overflow-y: auto; + margin-top: 8px; +} + +.signal-timeline-lanes::-webkit-scrollbar { + width: 6px; +} + +.signal-timeline-lanes::-webkit-scrollbar-track { + background: var(--bg-secondary, #252525); + border-radius: 3px; +} + +.signal-timeline-lanes::-webkit-scrollbar-thumb { + background: var(--border-color, #444); + border-radius: 3px; +} + +.signal-timeline-lanes::-webkit-scrollbar-thumb:hover { + background: var(--text-dim, #666); } .signal-timeline-lane { @@ -315,6 +380,8 @@ margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-color, #333); + max-height: 80px; + overflow-y: auto; } .signal-timeline-annotation { diff --git a/static/js/components/signal-timeline.js b/static/js/components/signal-timeline.js index 2f779ec..c4bca50 100644 --- a/static/js/components/signal-timeline.js +++ b/static/js/components/signal-timeline.js @@ -17,7 +17,8 @@ const SignalTimeline = (function() { '2h': 2 * 60 * 60 * 1000 }, defaultWindow: '30m', - maxSignals: 50, + maxSignals: 100, // max signals to track in memory + maxDisplayedLanes: 15, // max lanes to show at once (scroll for more) burstThreshold: 5, // messages in burst window = burst burstWindow: 60 * 1000, // 1 minute updateInterval: 5000, // refresh every 5 seconds @@ -108,9 +109,35 @@ const SignalTimeline = (function() { const windowMs = config.timeWindows['2h']; signal.events = signal.events.filter(e => now - e.timestamp < windowMs); + // Prune old signals if we exceed max + if (state.signals.size > config.maxSignals) { + pruneOldSignals(); + } + return signal; } + /** + * Remove oldest/least active signals to stay under limit + */ + function pruneOldSignals() { + const signals = Array.from(state.signals.entries()); + // Sort by last seen (oldest first), but keep flagged signals + signals.sort((a, b) => { + if (a[1].flagged && !b[1].flagged) return 1; + if (!a[1].flagged && b[1].flagged) return -1; + return a[1].lastSeen - b[1].lastSeen; + }); + + // Remove oldest signals until under limit + const toRemove = signals.length - config.maxSignals; + for (let i = 0; i < toRemove; i++) { + if (!signals[i][1].flagged) { + state.signals.delete(signals[i][0]); + } + } + } + /** * Update signal status based on activity */ @@ -229,18 +256,39 @@ const SignalTimeline = (function() { /** * Create the timeline DOM element */ - function createTimeline(containerId) { + function createTimeline(containerId, options = {}) { const container = document.getElementById(containerId); if (!container) return null; + const startCollapsed = options.collapsed !== false; + const timeline = document.createElement('div'); - timeline.className = 'signal-timeline'; + timeline.className = 'signal-timeline' + (startCollapsed ? ' collapsed' : ''); timeline.id = 'signalTimeline'; timeline.innerHTML = ` -
- Signal Activity Timeline -
+
+
+ + Signal Activity Timeline +
+
+
+ 0 + signals +
+
+ 0 + new +
+
+ 0 + burst +
+
+
+
+
@@ -250,7 +298,7 @@ const SignalTimeline = (function() { -
+
Window:
-
-
-
-
-
📡
-
No signals recorded yet
-
Signals will appear as they are detected
+
+
+
+
📡
+
No signals recorded yet
+
Signals will appear as they are detected
+
-
- -
-
-
- New -
-
-
- Baseline -
-
-
- Burst -
-
-
- Flagged + +
+
+
+ New +
+
+
+ Baseline +
+
+
+ Burst +
+
+
+ Flagged +
`; @@ -312,9 +360,20 @@ const SignalTimeline = (function() { * Set up event listeners */ function setupEventListeners(timeline) { + // Collapse toggle + const header = timeline.querySelector('#timelineHeader'); + if (header) { + header.addEventListener('click', (e) => { + // Don't toggle if clicking on controls inside header + if (e.target.closest('button') || e.target.closest('select')) return; + timeline.classList.toggle('collapsed'); + }); + } + // Filter buttons timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => { - btn.addEventListener('click', () => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent collapse toggle const filter = btn.dataset.filter; state.filters[filter] = !state.filters[filter]; btn.classList.toggle('active', state.filters[filter]); @@ -325,6 +384,7 @@ const SignalTimeline = (function() { // Time window selector const windowSelect = timeline.querySelector('#timelineWindowSelect'); if (windowSelect) { + windowSelect.addEventListener('click', (e) => e.stopPropagation()); windowSelect.addEventListener('change', (e) => { state.timeWindow = e.target.value; render(); @@ -479,7 +539,11 @@ const SignalTimeline = (function() { return b.lastSeen - a.lastSeen; }); - // Render lanes + // Render lanes (limit displayed for performance) + const totalSignals = signals.length; + const displayedSignals = signals.slice(0, config.maxDisplayedLanes); + const hiddenCount = totalSignals - displayedSignals.length; + if (signals.length === 0) { lanesContainer.innerHTML = `
@@ -489,10 +553,21 @@ const SignalTimeline = (function() {
`; } else { - lanesContainer.innerHTML = signals.map(signal => + let html = displayedSignals.map(signal => renderLane(signal, startTime, now, windowMs) ).join(''); + // Show indicator if there are more signals + if (hiddenCount > 0) { + html += ` +
+ +${hiddenCount} more signals (scroll or adjust filters) +
+ `; + } + + lanesContainer.innerHTML = html; + // Add event listeners to new lanes lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => { const freq = lane.dataset.frequency; @@ -504,6 +579,15 @@ const SignalTimeline = (function() { }); } + // Update header stats + const allSignals = Array.from(state.signals.values()); + const statTotal = document.getElementById('timelineStatTotal'); + const statNew = document.getElementById('timelineStatNew'); + const statBurst = document.getElementById('timelineStatBurst'); + if (statTotal) statTotal.textContent = allSignals.length; + if (statNew) statNew.textContent = allSignals.filter(s => s.status === 'new').length; + if (statBurst) statBurst.textContent = allSignals.filter(s => s.status === 'burst').length; + // Render annotations renderAnnotations(annotationsContainer); }