/** * Spaxel Dashboard - Security Mode Panel * * Security mode controls, status display, learning progress, * and anomaly timeline UI. */ (function() { 'use strict'; // ── module state ────────────────────────────────────────────────────────── let _securityMode = 'disarmed'; // 'disarmed', 'learning', 'armed', 'alert' let _armedAt = null; let _learningUntil = null; let _learningProgress = 0; // 0.0 - 1.0 let _anomalyCount24h = 0; let _lastAnomaly = null; let _modelReady = false; // API endpoints const API = { status: '/api/security/status', arm: '/api/security/arm', disarm: '/api/security/disarm', anomalies: '/api/anomalies', activeAnomalies: '/api/anomalies/active', anomalyHistory: '/api/anomalies/history', learning: '/api/anomalies/learning' }; // DOM elements (lazy-initialized) let _statusIndicator = null; let _alertBanner = null; // Callbacks let _onModeChange = null; // ── initialization ──────────────────────────────────────────────────────── function init() { // Create security status indicator in status bar ensureStatusIndicator(); // Start polling for security status startPolling(); // Listen for WebSocket messages if (window.SpaxelApp) { SpaxelApp.registerMessageHandler(handleWebSocketMessage); } // Subscribe to state changes if (window.SpaxelState) { SpaxelState.subscribe('system.security_mode', handleSecurityModeChange); SpaxelState.subscribe('alerts', handleAlertsChange); } console.log('[SecurityPanel] Module initialized'); } function ensureStatusIndicator() { // Check if indicator already exists _statusIndicator = document.getElementById('security-status-indicator'); if (_statusIndicator) return; // Find the security status container const container = document.getElementById('security-status-container'); if (!container) return; const indicator = document.createElement('div'); indicator.id = 'security-status-indicator'; indicator.className = 'security-status-indicator mode-disarmed'; indicator.innerHTML = ` 🛡️ Disarmed `; container.appendChild(indicator); _statusIndicator = indicator; // Add click handler for mode toggle const toggleBtn = indicator.querySelector('.security-toggle-btn'); if (toggleBtn) { toggleBtn.addEventListener('click', openSecurityDialog); } } // ── polling ──────────────────────────────────────────────────────────────── function startPolling() { fetchSecurityStatus(); setInterval(fetchSecurityStatus, 10000); // Poll every 10 seconds setInterval(fetchAnomalyCount, 30000); // Update anomaly count every 30s setInterval(fetchLearningProgress, 60000); // Update learning progress every minute } function fetchSecurityStatus() { fetch(API.status) .then(res => res.json()) .then(data => { updateSecurityStatus(data); }) .catch(err => { console.error('[SecurityPanel] Failed to fetch security status:', err); }); } function fetchAnomalyCount() { fetch(API.anomalyHistory + '?since=24h&limit=1') .then(res => res.json()) .then(data => { if (data.history && Array.isArray(data.history)) { _anomalyCount24h = data.total || data.history.length; if (data.history.length > 0) { _lastAnomaly = data.history[0]; } updateStatusIndicator(); } }) .catch(err => { console.error('[SecurityPanel] Failed to fetch anomaly count:', err); }); } function fetchLearningProgress() { fetch(API.learning) .then(res => res.json()) .then(data => { _learningProgress = data.progress || 0; _modelReady = data.ready || false; if (data.learning_until) { _learningUntil = data.learning_until; } updateLearningProgress(); updateStatusIndicator(); }) .catch(err => { console.error('[SecurityPanel] Failed to fetch learning progress:', err); }); } // ── status updates ──────────────────────────────────────────────────────── function updateSecurityStatus(data) { const prevMode = _securityMode; if (data.armed) { _securityMode = 'armed'; } else if (data.model_ready) { _securityMode = 'ready'; } else if (data.learning_until) { _securityMode = 'learning'; _learningUntil = data.learning_until; } else { _securityMode = 'disarmed'; } _modelReady = data.model_ready || false; _anomalyCount24h = data.anomaly_count_24h || 0; updateStatusIndicator(); // Update global state if (window.SpaxelState) { SpaxelState.set('system.security_mode', data.armed); } // Fire mode change callback if (prevMode !== _securityMode && _onModeChange) { _onModeChange(_securityMode, prevMode); } } function updateStatusIndicator() { if (!_statusIndicator) return; const textEl = _statusIndicator.querySelector('.security-text'); const iconEl = _statusIndicator.querySelector('.security-icon'); let text, icon, modeClass; switch (_securityMode) { case 'armed': text = 'ARMED'; icon = '🔴'; modeClass = 'mode-armed'; break; case 'alert': text = 'ALERT'; icon = '🚨'; modeClass = 'mode-alert'; break; case 'ready': text = 'READY'; icon = '🛡️'; modeClass = 'mode-ready'; break; case 'learning': text = 'LEARNING'; icon = '📚'; modeClass = 'mode-learning'; break; default: text = 'DISARMED'; icon = '🛡️'; modeClass = 'mode-disarmed'; break; } if (textEl) textEl.textContent = text; if (iconEl) iconEl.textContent = icon; // Update mode class _statusIndicator.classList.remove('mode-disarmed', 'mode-learning', 'mode-armed', 'mode-alert', 'mode-ready'); _statusIndicator.classList.add(modeClass); } function updateLearningProgress() { // Update learning banner if visible const banner = document.getElementById('anomaly-learning-banner'); if (!banner) return; const progressEl = banner.querySelector('.learning-progress'); const daysEl = banner.querySelector('.days-remaining'); if (_securityMode === 'learning' || !_modelReady) { banner.classList.add('visible'); if (progressEl) { progressEl.style.width = (_learningProgress * 100) + '%'; } if (daysEl) { const daysComplete = Math.floor(_learningProgress * 7); const daysTotal = 7; daysEl.textContent = `${daysComplete} of ${daysTotal} days complete`; } } else { banner.classList.remove('visible'); } } // ── state change handlers ───────────────────────────────────────────────────── function handleSecurityModeChange(armed) { // Update from global state changes if (armed) { _securityMode = 'armed'; } else { _securityMode = _modelReady ? 'ready' : 'disarmed'; } updateStatusIndicator(); } function handleAlertsChange(alerts) { // Check for active alerts const activeAlerts = alerts.filter(a => !a.acknowledged); if (activeAlerts.length > 0 && _securityMode === 'armed') { showAlertBanner(activeAlerts[0]); } } // ── security dialog ─────────────────────────────────────────────────────── function openSecurityDialog() { const isArmed = _securityMode === 'armed' || _securityMode === 'alert'; const action = isArmed ? 'Disarm' : 'Arm'; const actionClass = isArmed ? 'disarm' : 'arm'; const dialog = document.createElement('div'); dialog.className = 'security-dialog-overlay'; dialog.innerHTML = `

${action} Security Mode

${isArmed ? 'Disarming security mode will disable automatic intrusion detection.' : 'Arming security mode will enable automatic intrusion detection. Any motion detected will trigger an alert.' }

${!_modelReady ? `

⚠️ Warning: The system is still learning normal patterns.

Accuracy will improve over the next ${Math.ceil((1 - _learningProgress) * 7)} days.

` : ''}
Last 24h ${_anomalyCount24h} anomaly${_anomalyCount24h !== 1 ? 'ies' : ''}
${_lastAnomaly ? `
Last Event ${formatTimeAgo(_lastAnomaly.timestamp)}
` : ''} ${_lastAnomaly && _lastAnomaly.zone_name ? `
Location ${_lastAnomaly.zone_name}
` : ''}
`; document.body.appendChild(dialog); // Add event listeners const closeBtn = dialog.querySelector('.security-dialog-close'); const cancelBtn = dialog.querySelector('[data-action="cancel"]'); const actionBtn = dialog.querySelector('[data-action="arm"], [data-action="disarm"]'); if (closeBtn) closeBtn.addEventListener('click', closeSecurityDialog); if (cancelBtn) cancelBtn.addEventListener('click', closeSecurityDialog); if (actionBtn) { actionBtn.addEventListener('click', function() { const action = this.dataset.action; if (action === 'arm') arm(); else if (action === 'disarm') disarm(); }); } // Auto-close on backdrop click dialog.addEventListener('click', function(e) { if (e.target === dialog) { closeSecurityDialog(); } }); } function closeSecurityDialog() { const dialog = document.querySelector('.security-dialog-overlay'); if (dialog) { dialog.remove(); } } // ── arm/disarm actions ──────────────────────────────────────────────────────── function arm() { closeSecurityDialog(); fetch(API.arm, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ mode: 'armed' }) }) .then(res => { if (!res.ok) { throw new Error('Failed to arm: ' + res.status); } return res.json(); }) .then(data => { console.log('[SecurityPanel] Armed:', data); fetchSecurityStatus(); // Refresh status if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Security mode armed', 'warning'); } }) .catch(err => { console.error('[SecurityPanel] Arm failed:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to arm security mode', 'error'); } }); } function disarm() { closeSecurityDialog(); fetch(API.disarm, { method: 'POST', headers: {'Content-Type': 'application/json'} }) .then(res => { if (!res.ok) { throw new Error('Failed to disarm: ' + res.status); } return res.json(); }) .then(data => { console.log('[SecurityPanel] Disarmed:', data); fetchSecurityStatus(); // Refresh status if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Security mode disarmed', 'info'); } }) .catch(err => { console.error('[SecurityPanel] Disarm failed:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to disarm security mode', 'error'); } }); } // ── alert banner ──────────────────────────────────────────────────────────────── function showAlertBanner(anomaly) { ensureAlertBanner(); const title = _alertBanner.querySelector('.alert-banner-title'); const desc = _alertBanner.querySelector('.alert-banner-description'); const zone = _alertBanner.querySelector('.alert-banner-zone'); const time = _alertBanner.querySelector('.alert-banner-time'); const acknowledgeBtn = _alertBanner.querySelector('.alert-banner-acknowledge'); if (title) title.textContent = getAnomalyTitle(anomaly); if (desc) desc.textContent = anomaly.description || 'Anomaly detected'; if (zone) zone.textContent = anomaly.zone_name || 'Unknown zone'; if (time) time.textContent = formatTimeAgo(anomaly.timestamp); if (acknowledgeBtn) { acknowledgeBtn.onclick = function() { acknowledgeAnomaly(anomaly.id); }; } _alertBanner.classList.remove('hidden'); _alertBanner.classList.add('visible'); _alertBanner.dataset.anomalyId = anomaly.id; // Play alert sound playAlertSound(); // Add to global state if (window.SpaxelState) { SpaxelState.addAlert({ id: anomaly.id, type: 'anomaly', severity: anomaly.severity || 'critical', title: getAnomalyTitle(anomaly), message: anomaly.description, timestamp_ms: anomaly.timestamp }); } } function hideAlertBanner() { if (_alertBanner) { _alertBanner.classList.remove('visible'); _alertBanner.classList.add('hidden'); } } function ensureAlertBanner() { if (_alertBanner) return; _alertBanner = document.createElement('div'); _alertBanner.id = 'alert-banner'; _alertBanner.className = 'alert-banner hidden'; _alertBanner.innerHTML = `
⚠️
Anomaly Detected
Motion detected in unusual location
Kitchen 2 minutes ago
`; document.body.appendChild(_alertBanner); // Add event listener for view button const viewBtn = _alertBanner.querySelector('.alert-banner-btn.view'); if (viewBtn) { viewBtn.addEventListener('click', function() { hideAlertBanner(); if (window.TimelineView) { TimelineView.show(); } else if (window.SpaxelRouter) { SpaxelRouter.setMode('timeline'); } }); } } function acknowledgeAnomaly(anomalyId) { fetch(`/api/anomalies/${anomalyId}/acknowledge`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ feedback: 'false_alarm', acknowledged_by: 'dashboard_user' }) }) .then(res => { if (!res.ok) { throw new Error('Failed to acknowledge: ' + res.status); } return res.json(); }) .then(() => { hideAlertBanner(); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Anomaly acknowledged', 'success'); } // Update global state if (window.SpaxelState) { SpaxelState.acknowledgeAlert(anomalyId); } }) .catch(err => { console.error('[SecurityPanel] Acknowledge failed:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to acknowledge anomaly', 'error'); } }); } function playAlertSound() { try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.type = 'square'; oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.1); oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.2); gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + 0.5); } catch (e) { console.warn('[SecurityPanel] Could not play alert sound:', e); } } // ── helpers ───────────────────────────────────────────────────────────────── function formatTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; if (diff < 60000) { const secs = Math.floor(diff / 1000); return secs + ' sec' + (secs !== 1 ? 's' : '') + ' ago'; } else if (diff < 3600000) { const mins = Math.floor(diff / 60000); return mins + ' min' + (mins !== 1 ? 's' : '') + ' ago'; } else if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return hours + ' hour' + (hours !== 1 ? 's' : '') + ' ago'; } else { const days = Math.floor(diff / 86400000); return days + ' day' + (days !== 1 ? 's' : '') + ' ago'; } } function getAnomalyTitle(anomaly) { switch (anomaly.type) { case 'unknown_ble': return 'Unknown Device Detected'; case 'motion_during_away': return 'Motion Detected'; case 'unusual_hour': return 'Unusual Activity'; case 'unusual_dwell': return 'Unusual Dwell Time'; default: return 'Anomaly Detected'; } } // ── WebSocket message handling ─────────────────────────────────────────────── function handleWebSocketMessage(msg) { switch (msg.type) { case 'system_mode_change': if (msg.data) { updateSecurityStatus({ armed: msg.data.armed || msg.data.mode === 'away' }); } break; case 'anomaly_detected': if (msg.data && _securityMode === 'armed') { showAlertBanner(msg.data); } break; case 'security_mode': if (msg.data) { updateSecurityStatus(msg.data); } break; } } // ── public API ────────────────────────────────────────────────────────────── window.SecurityPanel = { init: init, arm: arm, disarm: disarm, openSecurityDialog: openSecurityDialog, closeSecurityDialog: closeSecurityDialog, acknowledgeAnomaly: acknowledgeAnomaly, getSecurityMode: function() { return _securityMode; }, isLearning: function() { return _securityMode === 'learning' || !_modelReady; }, isReady: function() { return _modelReady; }, isArmed: function() { return _securityMode === 'armed' || _securityMode === 'alert'; }, setOnModeChange: function(cb) { _onModeChange = cb; }, showAlertBanner: showAlertBanner, hideAlertBanner: hideAlertBanner }; // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();