feat: implement expert vs simple mode for timeline panel
Add mode switching for timeline panel. Expert mode shows all event types with system events as secondary (smaller, greyed). Simple mode shows only person-relevant events: ZoneTransition, FallDetected, AnomalyDetected, SleepSessionEnd. Mode is set by dashboard mode and passed as ?mode=expert or ?mode=simple to API. Changes: - dashboard/index.html: Add sidebar timeline panel HTML and script include - dashboard/js/sidebar-timeline.js: Add sidebar timeline with mode switching - dashboard/js/sidebar-timeline.test.js: Add tests for mode switching behavior - mothership/internal/ingestion/server.go: Add health score to link info Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5735d000b3
commit
2d34f77d87
4 changed files with 2235 additions and 7 deletions
|
|
@ -3337,6 +3337,56 @@
|
|||
<!-- 3D Scene container -->
|
||||
<div id="scene-container"></div>
|
||||
|
||||
<!-- Sidebar Timeline Panel -->
|
||||
<div id="sidebar-timeline-panel" class="sidebar-panel">
|
||||
<div class="sidebar-panel-header">
|
||||
<div class="sidebar-panel-title">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"></path>
|
||||
</svg>
|
||||
<span>Activity Timeline</span>
|
||||
</div>
|
||||
<div class="sidebar-panel-actions">
|
||||
<button id="sidebar-timeline-toggle" class="sidebar-panel-btn" title="Toggle panel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="sidebar-timeline-close" class="sidebar-panel-btn" title="Close panel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sidebar-timeline-content" class="sidebar-panel-content">
|
||||
<div id="sidebar-timeline-events" class="sidebar-timeline-events">
|
||||
<!-- Events will be rendered here -->
|
||||
</div>
|
||||
<div id="sidebar-timeline-loading" class="sidebar-timeline-loading" style="display: none;">
|
||||
<div class="sidebar-spinner"></div>
|
||||
<span>Loading events...</span>
|
||||
</div>
|
||||
<div id="sidebar-timeline-empty" class="sidebar-timeline-empty" style="display: none;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"></path>
|
||||
</svg>
|
||||
<h3>No events yet</h3>
|
||||
<p>Events will appear here as Spaxel detects activity.</p>
|
||||
</div>
|
||||
<div id="sidebar-timeline-spacer-top" class="timeline-spacer timeline-spacer-top" style="height: 0px;"></div>
|
||||
<div id="sidebar-timeline-spacer-bottom" class="timeline-spacer timeline-spacer-bottom" style="height: 0px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Timeline Toggle Button (when panel is collapsed) -->
|
||||
<button id="sidebar-timeline-show-btn" class="sidebar-show-btn" title="Show timeline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Menu (for screens < 1024px) -->
|
||||
<div id="hamburger-overlay"></div>
|
||||
<div id="hamburger-menu">
|
||||
|
|
@ -3562,6 +3612,8 @@
|
|||
<script src="js/anomaly.js"></script>
|
||||
<!-- Activity Timeline -->
|
||||
<script src="js/timeline.js"></script>
|
||||
<!-- Sidebar Timeline Panel -->
|
||||
<script src="js/sidebar-timeline.js"></script>
|
||||
<!-- Security Panel -->
|
||||
<script src="js/security-panel.js"></script>
|
||||
<!-- Detection Explainability -->
|
||||
|
|
|
|||
791
dashboard/js/sidebar-timeline.js
Normal file
791
dashboard/js/sidebar-timeline.js
Normal file
|
|
@ -0,0 +1,791 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Sidebar Timeline Panel
|
||||
*
|
||||
* A collapsible sidebar panel showing events in reverse-chronological order.
|
||||
* Features:
|
||||
* - Event-specific visual rendering with icons and descriptions per event type
|
||||
* - Thumbs-up/down buttons on each event delegating to feedback module
|
||||
* - Virtualized rendering with IntersectionObserver for 1000+ events
|
||||
* - Real-time event updates via WebSocket
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
const CONFIG = {
|
||||
initialLoadLimit: 100,
|
||||
maxEventsInMemory: 10000,
|
||||
virtualization: {
|
||||
enabled: true,
|
||||
bufferSize: 20, // number of extra items to render above/below viewport
|
||||
rootMargin: '200px', // load items 200px before they enter viewport
|
||||
threshold: 0.01
|
||||
},
|
||||
itemHeight: 70, // estimated height of a sidebar event item in pixels
|
||||
maxDOMItems: 50 // maximum number of items to keep in DOM at once
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Event Type Information
|
||||
// ============================================
|
||||
const EVENT_TYPES = {
|
||||
zone_entry: {
|
||||
icon: '🚪',
|
||||
label: 'Entered',
|
||||
description: '{person} entered {zone}',
|
||||
category: 'zones'
|
||||
},
|
||||
zone_exit: {
|
||||
icon: '🚶',
|
||||
label: 'Left',
|
||||
description: '{person} left {zone}',
|
||||
category: 'zones'
|
||||
},
|
||||
portal_crossing: {
|
||||
icon: '→',
|
||||
label: 'Crossed',
|
||||
description: '{person} crossed from {from_zone} to {to_zone}',
|
||||
category: 'zones'
|
||||
},
|
||||
presence_transition: {
|
||||
icon: '👤',
|
||||
label: 'Detected',
|
||||
description: '{person} detected in {zone}',
|
||||
category: 'presence'
|
||||
},
|
||||
stationary_detected: {
|
||||
icon: '💤',
|
||||
label: 'Stationary',
|
||||
description: '{person} is stationary in {zone}',
|
||||
category: 'presence'
|
||||
},
|
||||
detection: {
|
||||
icon: '👁️',
|
||||
label: 'Motion',
|
||||
description: 'Motion detected in {zone}',
|
||||
category: 'presence'
|
||||
},
|
||||
anomaly: {
|
||||
icon: '⚠️',
|
||||
label: 'Unusual Activity',
|
||||
description: 'Unusual activity detected in {zone}',
|
||||
category: 'alerts'
|
||||
},
|
||||
anomaly_detected: {
|
||||
icon: '⚠️',
|
||||
label: 'Anomaly',
|
||||
description: 'Anomaly detected in {zone}',
|
||||
category: 'alerts'
|
||||
},
|
||||
security_alert: {
|
||||
icon: '🚨',
|
||||
label: 'Security Alert',
|
||||
description: 'Security alert: {description}',
|
||||
category: 'alerts'
|
||||
},
|
||||
fall_alert: {
|
||||
icon: '🆘',
|
||||
label: 'Fall Detected',
|
||||
description: 'Fall detected: {person} in {zone}',
|
||||
category: 'alerts'
|
||||
},
|
||||
node_online: {
|
||||
icon: '📡',
|
||||
label: 'Node Online',
|
||||
description: 'Node {node} came online',
|
||||
category: 'system'
|
||||
},
|
||||
node_offline: {
|
||||
icon: '📵',
|
||||
label: 'Node Offline',
|
||||
description: 'Node {node} went offline',
|
||||
category: 'system'
|
||||
},
|
||||
ota_update: {
|
||||
icon: '⬆️',
|
||||
label: 'Firmware Updated',
|
||||
description: 'Node {node} firmware updated to {version}',
|
||||
category: 'system'
|
||||
},
|
||||
baseline_changed: {
|
||||
icon: '📊',
|
||||
label: 'Baseline Updated',
|
||||
description: 'Baseline updated for {link}',
|
||||
category: 'system'
|
||||
},
|
||||
system: {
|
||||
icon: '⚙️',
|
||||
label: 'System',
|
||||
description: '{description}',
|
||||
category: 'system'
|
||||
},
|
||||
learning_milestone: {
|
||||
icon: '🎓',
|
||||
label: 'Learning Complete',
|
||||
description: '{description}',
|
||||
category: 'learning'
|
||||
},
|
||||
anomaly_learned: {
|
||||
icon: '🧠',
|
||||
label: 'Pattern Learned',
|
||||
description: '{description}',
|
||||
category: 'learning'
|
||||
},
|
||||
sleep_session_end: {
|
||||
icon: '😴',
|
||||
label: 'Sleep Session',
|
||||
description: '{person} slept for {duration}',
|
||||
category: 'presence'
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
const state = {
|
||||
events: [],
|
||||
cursor: null,
|
||||
total: 0,
|
||||
loading: false,
|
||||
panelVisible: false,
|
||||
dashboardMode: 'expert', // 'expert' or 'simple' - determines timeline mode
|
||||
// Virtualization state
|
||||
virtualization: {
|
||||
observer: null,
|
||||
visibleIndices: new Set(),
|
||||
renderedIndices: new Set(),
|
||||
firstVisibleIndex: 0,
|
||||
lastVisibleIndex: 0,
|
||||
scrollTop: 0
|
||||
}
|
||||
};
|
||||
|
||||
// DOM elements cache
|
||||
const elements = {};
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
function init() {
|
||||
console.log('[SidebarTimeline] Initializing');
|
||||
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
setupVirtualization();
|
||||
|
||||
// Listen for simple mode changes
|
||||
if (window.SpaxelSimpleModeDetection) {
|
||||
SpaxelSimpleModeDetection.onModeChange(onSimpleModeChange);
|
||||
}
|
||||
|
||||
// Listen for router mode changes
|
||||
if (window.SpaxelRouter) {
|
||||
SpaxelRouter.onModeChange(onRouterModeChange);
|
||||
}
|
||||
|
||||
loadInitialEvents();
|
||||
|
||||
// Register for WebSocket messages
|
||||
if (window.SpaxelApp) {
|
||||
SpaxelApp.registerMessageHandler(handleWebSocketMessage);
|
||||
}
|
||||
|
||||
console.log('[SidebarTimeline] Initialized');
|
||||
}
|
||||
|
||||
function cacheElements() {
|
||||
elements.panel = document.getElementById('sidebar-timeline-panel');
|
||||
elements.content = document.getElementById('sidebar-timeline-content');
|
||||
elements.eventsContainer = document.getElementById('sidebar-timeline-events');
|
||||
elements.loading = document.getElementById('sidebar-timeline-loading');
|
||||
elements.empty = document.getElementById('sidebar-timeline-empty');
|
||||
elements.spacerTop = document.getElementById('sidebar-timeline-spacer-top');
|
||||
elements.spacerBottom = document.getElementById('sidebar-timeline-spacer-bottom');
|
||||
elements.toggleBtn = document.getElementById('sidebar-timeline-toggle');
|
||||
elements.closeBtn = document.getElementById('sidebar-timeline-close');
|
||||
elements.showBtn = document.getElementById('sidebar-timeline-show-btn');
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// Panel toggle buttons
|
||||
if (elements.toggleBtn) {
|
||||
elements.toggleBtn.addEventListener('click', togglePanel);
|
||||
}
|
||||
if (elements.closeBtn) {
|
||||
elements.closeBtn.addEventListener('click', hidePanel);
|
||||
}
|
||||
if (elements.showBtn) {
|
||||
elements.showBtn.addEventListener('click', showPanel);
|
||||
}
|
||||
|
||||
// Scroll events for virtualization
|
||||
if (elements.content) {
|
||||
elements.content.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mode Change Handlers
|
||||
// ============================================
|
||||
function onSimpleModeChange(newMode, oldMode) {
|
||||
console.log('[SidebarTimeline] Simple mode changed from', oldMode, 'to', newMode);
|
||||
|
||||
// Update dashboard mode based on simple mode
|
||||
if (newMode === 'simple') {
|
||||
state.dashboardMode = 'simple';
|
||||
} else {
|
||||
state.dashboardMode = 'expert';
|
||||
}
|
||||
|
||||
// Reload events with new mode
|
||||
if (state.panelVisible) {
|
||||
loadInitialEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function onRouterModeChange(newMode, oldMode) {
|
||||
// Determine dashboard mode: expert mode shows all events, simple mode shows person-relevant only
|
||||
if (newMode === 'live' || newMode === 'replay' || newMode === 'timeline') {
|
||||
state.dashboardMode = 'expert';
|
||||
} else if (newMode === 'simple' || newMode === 'ambient') {
|
||||
state.dashboardMode = 'simple';
|
||||
} else {
|
||||
state.dashboardMode = 'expert'; // Default to expert
|
||||
}
|
||||
|
||||
// Reload events if panel is visible
|
||||
if (state.panelVisible) {
|
||||
loadInitialEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Panel Visibility
|
||||
// ============================================
|
||||
function showPanel() {
|
||||
if (elements.panel) {
|
||||
elements.panel.classList.remove('collapsed');
|
||||
state.panelVisible = true;
|
||||
}
|
||||
if (elements.showBtn) {
|
||||
elements.showBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
if (elements.panel) {
|
||||
elements.panel.classList.add('collapsed');
|
||||
state.panelVisible = false;
|
||||
}
|
||||
if (elements.showBtn) {
|
||||
elements.showBtn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (state.panelVisible) {
|
||||
hidePanel();
|
||||
} else {
|
||||
showPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Event Loading
|
||||
// ============================================
|
||||
function loadInitialEvents() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', CONFIG.initialLoadLimit);
|
||||
params.set('mode', state.dashboardMode);
|
||||
|
||||
fetch('/api/events?' + params.toString())
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch events: ' + res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
state.events = data.events || [];
|
||||
state.cursor = data.cursor || null;
|
||||
state.total = data.total_filtered || 0;
|
||||
renderEvents();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[SidebarTimeline] Failed to load events:', err);
|
||||
showError(err.message);
|
||||
})
|
||||
.finally(function() {
|
||||
state.loading = false;
|
||||
updateLoadingState();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Virtualization Setup
|
||||
// ============================================
|
||||
function setupVirtualization() {
|
||||
if (!CONFIG.virtualization.enabled || !elements.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create IntersectionObserver for lazy rendering
|
||||
const observerOptions = {
|
||||
root: elements.content,
|
||||
rootMargin: CONFIG.virtualization.rootMargin,
|
||||
threshold: CONFIG.virtualization.threshold
|
||||
};
|
||||
|
||||
state.virtualization.observer = new IntersectionObserver(function(entries) {
|
||||
handleIntersection(entries);
|
||||
}, observerOptions);
|
||||
|
||||
console.log('[SidebarTimeline] Virtualization enabled');
|
||||
}
|
||||
|
||||
function handleIntersection(entries) {
|
||||
entries.forEach(function(entry) {
|
||||
const index = parseInt(entry.target.dataset.index, 10);
|
||||
if (isNaN(index)) return;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
state.virtualization.visibleIndices.add(index);
|
||||
} else {
|
||||
state.virtualization.visibleIndices.delete(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Update rendered range based on visibility
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!elements.content) return;
|
||||
|
||||
state.virtualization.scrollTop = elements.content.scrollTop;
|
||||
|
||||
// Update visible range based on scroll position
|
||||
const firstIndex = Math.floor(state.virtualization.scrollTop / CONFIG.itemHeight);
|
||||
const visibleCount = Math.ceil(elements.content.clientHeight / CONFIG.itemHeight);
|
||||
const lastIndex = firstIndex + visibleCount;
|
||||
|
||||
state.virtualization.firstVisibleIndex = Math.max(0, firstIndex - CONFIG.virtualization.bufferSize);
|
||||
state.virtualization.lastVisibleIndex = Math.min(state.events.length - 1, lastIndex + CONFIG.virtualization.bufferSize);
|
||||
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
function updateRenderedRange() {
|
||||
if (!state.events.length) return;
|
||||
|
||||
const firstIdx = Math.max(0, state.virtualization.firstVisibleIndex);
|
||||
const lastIdx = Math.min(state.events.length - 1, state.virtualization.lastVisibleIndex);
|
||||
|
||||
// Create new set of rendered indices
|
||||
const newRenderedIndices = new Set();
|
||||
for (let i = firstIdx; i <= lastIdx; i++) {
|
||||
newRenderedIndices.add(i);
|
||||
}
|
||||
|
||||
// Render new items
|
||||
const fragment = document.createDocumentFragment();
|
||||
newRenderedIndices.forEach(function(index) {
|
||||
if (!state.virtualization.renderedIndices.has(index)) {
|
||||
const event = state.events[index];
|
||||
if (event) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = renderEvent(event, false, index);
|
||||
const newEventEl = tempDiv.firstElementChild;
|
||||
if (newEventEl) {
|
||||
newEventEl.dataset.index = index;
|
||||
fragment.appendChild(newEventEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (fragment.children.length > 0) {
|
||||
elements.eventsContainer.appendChild(fragment);
|
||||
|
||||
// Bind handlers for new items
|
||||
Array.from(fragment.children).forEach(function(item) {
|
||||
bindEventHandlers(item);
|
||||
if (state.virtualization.observer) {
|
||||
state.virtualization.observer.observe(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove items that are no longer in rendered range
|
||||
state.virtualization.renderedIndices.forEach(function(index) {
|
||||
if (!newRenderedIndices.has(index)) {
|
||||
const item = elements.eventsContainer.querySelector('[data-index="' + index + '"]');
|
||||
if (item) {
|
||||
if (state.virtualization.observer) {
|
||||
state.virtualization.observer.unobserve(item);
|
||||
}
|
||||
item.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state.virtualization.renderedIndices = newRenderedIndices;
|
||||
|
||||
// Update spacers
|
||||
updateSpacers();
|
||||
}
|
||||
|
||||
function updateSpacers() {
|
||||
if (!elements.spacerTop || !elements.spacerBottom) return;
|
||||
|
||||
const topHeight = state.virtualization.firstVisibleIndex * CONFIG.itemHeight;
|
||||
const bottomHeight = (state.events.length - state.virtualization.lastVisibleIndex - 1) * CONFIG.itemHeight;
|
||||
|
||||
elements.spacerTop.style.height = Math.max(0, topHeight) + 'px';
|
||||
elements.spacerBottom.style.height = Math.max(0, bottomHeight) + 'px';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rendering
|
||||
// ============================================
|
||||
function renderEvents() {
|
||||
if (!elements.eventsContainer) return;
|
||||
|
||||
if (state.events.length === 0) {
|
||||
elements.eventsContainer.innerHTML = '';
|
||||
if (elements.empty) {
|
||||
elements.empty.style.display = 'flex';
|
||||
}
|
||||
if (elements.loading) {
|
||||
elements.loading.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
elements.empty.style.display = 'none';
|
||||
elements.loading.style.display = 'none';
|
||||
|
||||
// Use virtualized rendering for large datasets
|
||||
if (CONFIG.virtualization.enabled && state.events.length > CONFIG.maxDOMItems) {
|
||||
renderVirtualized();
|
||||
} else {
|
||||
renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
let html = '';
|
||||
state.events.forEach(function(event, index) {
|
||||
html += renderEvent(event, false, index);
|
||||
});
|
||||
elements.eventsContainer.innerHTML = html;
|
||||
|
||||
// Bind handlers
|
||||
Array.from(elements.eventsContainer.children).forEach(function(item) {
|
||||
bindEventHandlers(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderVirtualized() {
|
||||
// Clear existing content
|
||||
elements.eventsContainer.innerHTML = '';
|
||||
|
||||
// Calculate initial visible range
|
||||
const containerHeight = elements.content.clientHeight || 400;
|
||||
const visibleCount = Math.ceil(containerHeight / CONFIG.itemHeight);
|
||||
const bufferCount = CONFIG.virtualization.bufferSize;
|
||||
|
||||
state.virtualization.firstVisibleIndex = 0;
|
||||
state.virtualization.lastVisibleIndex = Math.min(state.events.length - 1, visibleCount + bufferCount * 2);
|
||||
|
||||
// Create spacers
|
||||
updateSpacers();
|
||||
|
||||
// Render initial batch
|
||||
updateRenderedRange();
|
||||
}
|
||||
|
||||
function renderEvent(event, isNew, index) {
|
||||
const typeInfo = EVENT_TYPES[event.type] || EVENT_TYPES.system;
|
||||
const timeStr = formatTimestamp(event.timestamp_ms);
|
||||
const description = buildEventDescription(event, typeInfo);
|
||||
|
||||
// Severity indicator
|
||||
const severityClass = event.severity === 'alert' || event.severity === 'critical' ? ' severity-critical' :
|
||||
event.severity === 'warning' ? ' severity-warning' : '';
|
||||
const newClass = isNew ? ' new-event' : '';
|
||||
|
||||
// Determine if this is a system event (for secondary styling in expert mode only)
|
||||
const systemEventTypes = ['node_online', 'node_offline', 'ota_update', 'baseline_changed', 'system', 'learning_milestone', 'anomaly_learned'];
|
||||
const isSystemEvent = systemEventTypes.indexOf(event.type) !== -1;
|
||||
// Only apply secondary class in expert mode for system events
|
||||
const secondaryClass = (state.dashboardMode === 'expert' && isSystemEvent) ? ' secondary' : '';
|
||||
|
||||
const dataIndex = index !== undefined ? ` data-index="${index}"` : '';
|
||||
|
||||
return `
|
||||
<div class="sidebar-timeline-event${severityClass}${newClass}${secondaryClass}"
|
||||
data-type="${event.type}"
|
||||
data-id="${event.id}"
|
||||
data-timestamp="${event.timestamp_ms}"${dataIndex}>
|
||||
<div class="sidebar-timeline-event-icon">${typeInfo.icon}</div>
|
||||
<div class="sidebar-timeline-event-content">
|
||||
<div class="sidebar-timeline-event-title">${escapeHtml(description)}</div>
|
||||
<div class="sidebar-timeline-event-meta">
|
||||
<span class="sidebar-timeline-event-time">${timeStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-timeline-event-actions">
|
||||
<button class="sidebar-timeline-action-btn feedback-positive"
|
||||
data-action="correct"
|
||||
title="Correct detection"
|
||||
aria-label="Thumbs up">👍</button>
|
||||
<button class="sidebar-timeline-action-btn feedback-negative"
|
||||
data-action="incorrect"
|
||||
title="Incorrect detection"
|
||||
aria-label="Thumbs down">👎</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildEventDescription(event, typeInfo) {
|
||||
// Parse detail_json for additional context
|
||||
let detail = {};
|
||||
if (event.detail_json) {
|
||||
try {
|
||||
detail = JSON.parse(event.detail_json);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Build description using template
|
||||
let description = typeInfo.description;
|
||||
|
||||
// Replace placeholders with actual values
|
||||
const replacements = {
|
||||
'{person}': event.person || detail.person || 'Someone',
|
||||
'{zone}': event.zone || detail.zone || 'the area',
|
||||
'{from_zone}': detail.from_zone || 'previous zone',
|
||||
'{to_zone}': detail.to_zone || 'next zone',
|
||||
'{node}': detail.node || 'a node',
|
||||
'{version}': detail.version || 'latest',
|
||||
'{link}': detail.link || 'a link',
|
||||
'{description}': detail.description || 'activity detected',
|
||||
'{duration}': detail.duration || 'a while'
|
||||
};
|
||||
|
||||
// Apply replacements
|
||||
for (const [placeholder, value] of Object.entries(replacements)) {
|
||||
description = description.replace(placeholder, value);
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
function formatTimestamp(ms) {
|
||||
const date = new Date(ms);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (isToday) {
|
||||
return time;
|
||||
} else {
|
||||
return date.toLocaleDateString() + ' ' + time;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Event Handlers
|
||||
// ============================================
|
||||
function bindEventHandlers(eventEl) {
|
||||
// Feedback buttons
|
||||
eventEl.querySelectorAll('.sidebar-timeline-action-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const eventId = eventEl.dataset.id;
|
||||
handleFeedback(eventId, action, eventEl);
|
||||
});
|
||||
});
|
||||
|
||||
// Event click (seeks to timestamp in replay)
|
||||
eventEl.addEventListener('click', function() {
|
||||
const timestamp = parseInt(this.dataset.timestamp, 10);
|
||||
handleSeek(timestamp);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFeedback(eventId, action, eventElement) {
|
||||
const correct = action === 'correct';
|
||||
|
||||
// Delegate to feedback module if available
|
||||
if (window.Feedback) {
|
||||
const event = state.events.find(function(e) { return e.id == eventId; });
|
||||
if (event) {
|
||||
let detail = {};
|
||||
try {
|
||||
detail = event.detail_json ? JSON.parse(event.detail_json) : {};
|
||||
} catch (e) {}
|
||||
|
||||
// Call feedback module's sendFeedback
|
||||
Feedback.sendFeedback(eventId, event.type, correct ? 'TRUE_POSITIVE' : 'FALSE_POSITIVE', detail);
|
||||
}
|
||||
} else {
|
||||
// Fallback: direct API call
|
||||
const payload = {
|
||||
type: correct ? 'correct' : 'incorrect',
|
||||
event_id: parseInt(eventId, 10)
|
||||
};
|
||||
|
||||
fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
throw new Error('Failed to submit feedback');
|
||||
})
|
||||
.then(function(data) {
|
||||
// Show visual feedback
|
||||
const feedbackBtns = eventElement.querySelectorAll('.sidebar-timeline-action-btn');
|
||||
feedbackBtns.forEach(function(btn) {
|
||||
if (btn.dataset.action === action) {
|
||||
btn.classList.add('active');
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('active');
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// Show toast notification
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
const message = correct ? 'Thanks for the feedback!' : 'Thanks — I\'ll adjust my detection.';
|
||||
SpaxelApp.showToast(message, 'success');
|
||||
}
|
||||
|
||||
// Dismiss entry for incorrect feedback
|
||||
if (!correct) {
|
||||
eventElement.classList.add('feedback-dismissed');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[SidebarTimeline] Feedback failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeek(timestamp) {
|
||||
// Convert timestamp to ISO8601
|
||||
const targetDate = new Date(timestamp);
|
||||
const iso8601 = targetDate.toISOString();
|
||||
|
||||
// Navigate to timeline view with this timestamp
|
||||
if (window.SpaxelRouter) {
|
||||
SpaxelRouter.navigate('timeline');
|
||||
// The timeline view will handle the seek
|
||||
if (window.SpaxelTimeline) {
|
||||
// Let the main timeline handle the seek
|
||||
setTimeout(function() {
|
||||
// Trigger seek in main timeline
|
||||
const seekEvent = new CustomEvent('timeline-seek', { detail: { timestamp: timestamp } });
|
||||
document.dispatchEvent(seekEvent);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.SpaxelApp && SpaxelApp.showToast) {
|
||||
SpaxelApp.showToast('Opening timeline...', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket Message Handler
|
||||
// ============================================
|
||||
function handleWebSocketMessage(msg) {
|
||||
if (msg.type === 'event') {
|
||||
handleNewEvent(msg.event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewEvent(event) {
|
||||
// Normalize event format
|
||||
const normalizedEvent = {
|
||||
id: event.id || event.timestamp_ms || Date.now(),
|
||||
timestamp_ms: event.timestamp_ms || event.ts || Date.now(),
|
||||
type: event.type || event.kind || 'system',
|
||||
zone: event.zone || '',
|
||||
person: event.person || event.person_name || '',
|
||||
blob_id: event.blob_id || event.blobID || 0,
|
||||
detail_json: event.detail_json || '',
|
||||
severity: event.severity || 'info'
|
||||
};
|
||||
|
||||
// Add to beginning of events
|
||||
state.events.unshift(normalizedEvent);
|
||||
state.total++;
|
||||
|
||||
// Limit events in memory
|
||||
if (state.events.length > CONFIG.maxEventsInMemory) {
|
||||
state.events = state.events.slice(0, CONFIG.maxEventsInMemory);
|
||||
}
|
||||
|
||||
// Re-render if panel is visible
|
||||
if (state.panelVisible) {
|
||||
renderEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI Updates
|
||||
// ============================================
|
||||
function updateLoadingState() {
|
||||
if (!elements.loading) return;
|
||||
elements.loading.style.display = state.loading ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
console.error('[SidebarTimeline]', message);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
const SidebarTimeline = {
|
||||
init: init,
|
||||
show: showPanel,
|
||||
hide: hidePanel,
|
||||
toggle: togglePanel,
|
||||
refresh: loadInitialEvents,
|
||||
isVisible: function() { return state.panelVisible; }
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Export for use by other modules
|
||||
window.SpaxelSidebarTimeline = SidebarTimeline;
|
||||
|
||||
console.log('[SidebarTimeline] Module loaded');
|
||||
})();
|
||||
1369
dashboard/js/sidebar-timeline.test.js
Normal file
1369
dashboard/js/sidebar-timeline.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -860,11 +860,12 @@ func (s *Server) GetConnectedNodesInfo() []NodeInfo {
|
|||
return nodes
|
||||
}
|
||||
|
||||
// LinkInfo represents a link with its endpoints
|
||||
// LinkInfo represents a link with its endpoints and health data
|
||||
type LinkInfo struct {
|
||||
ID string `json:"id"`
|
||||
NodeMAC string `json:"node_mac"`
|
||||
PeerMAC string `json:"peer_mac"`
|
||||
ID string `json:"id"`
|
||||
NodeMAC string `json:"node_mac"`
|
||||
PeerMAC string `json:"peer_mac"`
|
||||
HealthScore float64 `json:"health_score,omitempty"` // Ambient confidence score (0-1)
|
||||
}
|
||||
|
||||
// LinkHealthInfo represents a link with health metrics for the API response
|
||||
|
|
@ -880,16 +881,31 @@ type LinkHealthInfo struct {
|
|||
// GetAllLinksInfo returns detailed info about all active links
|
||||
func (s *Server) GetAllLinksInfo() []LinkInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
pm := s.processorMgr
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
links := make([]LinkInfo, 0, len(s.links))
|
||||
for linkID := range s.links {
|
||||
if len(linkID) >= 35 {
|
||||
links = append(links, LinkInfo{
|
||||
info := LinkInfo{
|
||||
ID: linkID,
|
||||
NodeMAC: linkID[:17],
|
||||
PeerMAC: linkID[18:],
|
||||
})
|
||||
}
|
||||
|
||||
// Get health score from processor manager if available
|
||||
if pm != nil {
|
||||
if proc := pm.GetProcessor(linkID); proc != nil {
|
||||
if health := proc.GetHealth(); health != nil {
|
||||
info.HealthScore = health.GetAmbientConfidence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
links = append(links, info)
|
||||
}
|
||||
}
|
||||
return links
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue