mirror of
https://github.com/smittix/intercept.git
synced 2026-07-15 13:08:10 -07:00
Fix timeline overwhelming TSCM page with many signals
- Make timeline collapsible (starts collapsed by default) - Add header stats showing signal counts when collapsed - Limit displayed lanes to 15 (scroll for more) - Constrain max-height to 180px with scrollbar - Add automatic pruning of old signals (keeps max 100) - Show "+N more signals" indicator when truncated - Reduce annotations max-height to 80px - Preserve flagged signals during pruning Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,17 +11,63 @@
|
|||||||
background: var(--bg-card, #1a1a1a);
|
background: var(--bg-card, #1a1a1a);
|
||||||
border: 1px solid var(--border-color, #333);
|
border: 1px solid var(--border-color, #333);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
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 {
|
.signal-timeline-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
padding: 10px 12px;
|
||||||
padding-bottom: 10px;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-color, #333);
|
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 {
|
.signal-timeline-title {
|
||||||
@@ -113,8 +159,27 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
max-height: 300px;
|
max-height: 180px;
|
||||||
overflow-y: auto;
|
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 {
|
.signal-timeline-lane {
|
||||||
@@ -315,6 +380,8 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--border-color, #333);
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signal-timeline-annotation {
|
.signal-timeline-annotation {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const SignalTimeline = (function() {
|
|||||||
'2h': 2 * 60 * 60 * 1000
|
'2h': 2 * 60 * 60 * 1000
|
||||||
},
|
},
|
||||||
defaultWindow: '30m',
|
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
|
burstThreshold: 5, // messages in burst window = burst
|
||||||
burstWindow: 60 * 1000, // 1 minute
|
burstWindow: 60 * 1000, // 1 minute
|
||||||
updateInterval: 5000, // refresh every 5 seconds
|
updateInterval: 5000, // refresh every 5 seconds
|
||||||
@@ -108,9 +109,35 @@ const SignalTimeline = (function() {
|
|||||||
const windowMs = config.timeWindows['2h'];
|
const windowMs = config.timeWindows['2h'];
|
||||||
signal.events = signal.events.filter(e => now - e.timestamp < windowMs);
|
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;
|
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
|
* Update signal status based on activity
|
||||||
*/
|
*/
|
||||||
@@ -229,18 +256,39 @@ const SignalTimeline = (function() {
|
|||||||
/**
|
/**
|
||||||
* Create the timeline DOM element
|
* Create the timeline DOM element
|
||||||
*/
|
*/
|
||||||
function createTimeline(containerId) {
|
function createTimeline(containerId, options = {}) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
|
|
||||||
|
const startCollapsed = options.collapsed !== false;
|
||||||
|
|
||||||
const timeline = document.createElement('div');
|
const timeline = document.createElement('div');
|
||||||
timeline.className = 'signal-timeline';
|
timeline.className = 'signal-timeline' + (startCollapsed ? ' collapsed' : '');
|
||||||
timeline.id = 'signalTimeline';
|
timeline.id = 'signalTimeline';
|
||||||
|
|
||||||
timeline.innerHTML = `
|
timeline.innerHTML = `
|
||||||
<div class="signal-timeline-header">
|
<div class="signal-timeline-header" id="timelineHeader">
|
||||||
<span class="signal-timeline-title">Signal Activity Timeline</span>
|
<div style="display: flex; align-items: center;">
|
||||||
<div class="signal-timeline-controls">
|
<span class="signal-timeline-collapse-icon">▼</span>
|
||||||
|
<span class="signal-timeline-title">Signal Activity Timeline</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-timeline-header-stats" id="timelineHeaderStats">
|
||||||
|
<div class="signal-timeline-header-stat">
|
||||||
|
<span class="stat-value" id="timelineStatTotal">0</span>
|
||||||
|
<span>signals</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-timeline-header-stat">
|
||||||
|
<span class="stat-value" id="timelineStatNew">0</span>
|
||||||
|
<span>new</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-timeline-header-stat">
|
||||||
|
<span class="stat-value" id="timelineStatBurst">0</span>
|
||||||
|
<span>burst</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-timeline-body">
|
||||||
|
<div class="signal-timeline-controls" style="display: flex; align-items: center; gap: 6px; padding: 8px 0; flex-wrap: wrap;">
|
||||||
<button class="signal-timeline-btn" data-filter="hideBaseline" title="Hide baseline signals">
|
<button class="signal-timeline-btn" data-filter="hideBaseline" title="Hide baseline signals">
|
||||||
Hide Known
|
Hide Known
|
||||||
</button>
|
</button>
|
||||||
@@ -250,7 +298,7 @@ const SignalTimeline = (function() {
|
|||||||
<button class="signal-timeline-btn" data-filter="showOnlyBurst" title="Show only burst activity">
|
<button class="signal-timeline-btn" data-filter="showOnlyBurst" title="Show only burst activity">
|
||||||
Bursts
|
Bursts
|
||||||
</button>
|
</button>
|
||||||
<div class="signal-timeline-window">
|
<div class="signal-timeline-window" style="margin-left: auto;">
|
||||||
<span>Window:</span>
|
<span>Window:</span>
|
||||||
<select id="timelineWindowSelect">
|
<select id="timelineWindowSelect">
|
||||||
<option value="5m">5 min</option>
|
<option value="5m">5 min</option>
|
||||||
@@ -261,32 +309,32 @@ const SignalTimeline = (function() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="signal-timeline-axis" id="timelineAxis"></div>
|
||||||
<div class="signal-timeline-axis" id="timelineAxis"></div>
|
<div class="signal-timeline-lanes" id="timelineLanes">
|
||||||
<div class="signal-timeline-lanes" id="timelineLanes">
|
<div class="signal-timeline-empty">
|
||||||
<div class="signal-timeline-empty">
|
<div class="signal-timeline-empty-icon">📡</div>
|
||||||
<div class="signal-timeline-empty-icon">📡</div>
|
<div>No signals recorded yet</div>
|
||||||
<div>No signals recorded yet</div>
|
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
|
||||||
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
|
||||||
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
|
<div class="signal-timeline-legend">
|
||||||
<div class="signal-timeline-legend">
|
<div class="signal-timeline-legend-item">
|
||||||
<div class="signal-timeline-legend-item">
|
<div class="signal-timeline-legend-dot new"></div>
|
||||||
<div class="signal-timeline-legend-dot new"></div>
|
<span>New</span>
|
||||||
<span>New</span>
|
</div>
|
||||||
</div>
|
<div class="signal-timeline-legend-item">
|
||||||
<div class="signal-timeline-legend-item">
|
<div class="signal-timeline-legend-dot baseline"></div>
|
||||||
<div class="signal-timeline-legend-dot baseline"></div>
|
<span>Baseline</span>
|
||||||
<span>Baseline</span>
|
</div>
|
||||||
</div>
|
<div class="signal-timeline-legend-item">
|
||||||
<div class="signal-timeline-legend-item">
|
<div class="signal-timeline-legend-dot burst"></div>
|
||||||
<div class="signal-timeline-legend-dot burst"></div>
|
<span>Burst</span>
|
||||||
<span>Burst</span>
|
</div>
|
||||||
</div>
|
<div class="signal-timeline-legend-item">
|
||||||
<div class="signal-timeline-legend-item">
|
<div class="signal-timeline-legend-dot flagged"></div>
|
||||||
<div class="signal-timeline-legend-dot flagged"></div>
|
<span>Flagged</span>
|
||||||
<span>Flagged</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -312,9 +360,20 @@ const SignalTimeline = (function() {
|
|||||||
* Set up event listeners
|
* Set up event listeners
|
||||||
*/
|
*/
|
||||||
function setupEventListeners(timeline) {
|
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
|
// Filter buttons
|
||||||
timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => {
|
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;
|
const filter = btn.dataset.filter;
|
||||||
state.filters[filter] = !state.filters[filter];
|
state.filters[filter] = !state.filters[filter];
|
||||||
btn.classList.toggle('active', state.filters[filter]);
|
btn.classList.toggle('active', state.filters[filter]);
|
||||||
@@ -325,6 +384,7 @@ const SignalTimeline = (function() {
|
|||||||
// Time window selector
|
// Time window selector
|
||||||
const windowSelect = timeline.querySelector('#timelineWindowSelect');
|
const windowSelect = timeline.querySelector('#timelineWindowSelect');
|
||||||
if (windowSelect) {
|
if (windowSelect) {
|
||||||
|
windowSelect.addEventListener('click', (e) => e.stopPropagation());
|
||||||
windowSelect.addEventListener('change', (e) => {
|
windowSelect.addEventListener('change', (e) => {
|
||||||
state.timeWindow = e.target.value;
|
state.timeWindow = e.target.value;
|
||||||
render();
|
render();
|
||||||
@@ -479,7 +539,11 @@ const SignalTimeline = (function() {
|
|||||||
return b.lastSeen - a.lastSeen;
|
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) {
|
if (signals.length === 0) {
|
||||||
lanesContainer.innerHTML = `
|
lanesContainer.innerHTML = `
|
||||||
<div class="signal-timeline-empty">
|
<div class="signal-timeline-empty">
|
||||||
@@ -489,10 +553,21 @@ const SignalTimeline = (function() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
lanesContainer.innerHTML = signals.map(signal =>
|
let html = displayedSignals.map(signal =>
|
||||||
renderLane(signal, startTime, now, windowMs)
|
renderLane(signal, startTime, now, windowMs)
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
// Show indicator if there are more signals
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-timeline-more" style="text-align: center; padding: 8px; font-size: 10px; color: var(--text-dim, #666);">
|
||||||
|
+${hiddenCount} more signals (scroll or adjust filters)
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
lanesContainer.innerHTML = html;
|
||||||
|
|
||||||
// Add event listeners to new lanes
|
// Add event listeners to new lanes
|
||||||
lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => {
|
lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => {
|
||||||
const freq = lane.dataset.frequency;
|
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
|
// Render annotations
|
||||||
renderAnnotations(annotationsContainer);
|
renderAnnotations(annotationsContainer);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user