/** * 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; // API endpoints const API = { status: '/api/security/status', arm: '/api/mode', // Uses POST with {mode: "away", reason: "manual"} disarm: '/api/mode', // Uses POST with {mode: "home", reason: "manual"} anomalies: '/api/anomalies', activeAnomalies: '/api/anomalies/active', anomalyHistory: '/api/anomalies/history', learning: '/api/anomalies/learning' }; // DOM elements (lazy-initialized) let _statusIndicator = null; let _securityCard = 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); } console.log('[SecurityPanel] Module initialized'); } function ensureStatusIndicator() { // Check if indicator already exists _statusIndicator = document.getElementById('security-status-indicator'); if (_statusIndicator) return; // Create indicator in status bar const statusBar = document.getElementById('status-bar'); if (!statusBar) return; const indicator = document.createElement('div'); indicator.id = 'security-status-indicator'; indicator.className = 'security-status-indicator'; indicator.innerHTML = ` 🛡️ Disarmed `; // Insert before FPS counter const fpsCounter = document.getElementById('fps-counter'); if (fpsCounter && fpsCounter.parentElement) { fpsCounter.parentElement.insertBefore(indicator, fpsCounter); } else { statusBar.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 + '?limit=100&since=' + (Date.now() - 24*60*60*1000)) .then(res => res.json()) .then(history => { if (Array.isArray(history)) { _anomalyCount24h = history.length; if (history.length > 0) { _lastAnomaly = history[0]; } updateSecurityCard(); } }) .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; _learningUntil = data.days_remaining || null; updateLearningProgress(); }) .catch(err => { console.error('[SecurityPanel] Failed to fetch learning progress:', err); }); } // ── status updates ──────────────────────────────────────────────────────── function updateSecurityStatus(data) { const prevMode = _securityMode; if (data.security_mode) { _securityMode = 'armed'; } else if (data.model_ready && data.learning_progress >= 1.0) { _securityMode = 'ready'; } else if (data.learning_progress > 0) { _securityMode = 'learning'; } else { _securityMode = 'disarmed'; } _armedAt = data.armed_at || null; updateStatusIndicator(); updateSecurityCard(); // Fire mode change callback if (prevMode !== _securityMode && _onModeChange) { _onModeChange(_securityMode, prevMode); } // Update learning progress if available if (data.learning_progress !== undefined) { _learningProgress = data.learning_progress; updateLearningProgress(); } } function updateStatusIndicator() { if (!_statusIndicator) return; const textEl = _statusIndicator.querySelector('.security-text'); const iconEl = _statusIndicator.querySelector('.security-icon'); let text, icon; switch (_securityMode) { case 'armed': text = 'ARMED'; icon = '🔴'; break; case 'alert': text = 'ALERT'; icon = '🚨'; break; case 'ready': text = 'READY'; icon = '🛡️'; break; case 'learning': text = 'LEARNING'; icon = '📚'; break; default: text = 'DISARMED'; icon = '🛡️'; break; } if (textEl) textEl.textContent = text; if (iconEl) iconEl.textContent = icon; _statusIndicator.dataset.mode = _securityMode; } function updateSecurityCard() { // If security card is visible, update it if (_securityCard) { renderSecurityCard(); } } 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') { banner.classList.add('visible'); if (progressEl) { progressEl.style.width = (_learningProgress * 100) + '%'; } if (daysEl) { const daysLeft = Math.ceil((1 - _learningProgress) * 7); daysEl.textContent = daysLeft > 0 ? daysLeft + ' days remaining' : 'Almost ready...'; } } else { banner.classList.remove('visible'); } } // ── 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.' }

${_securityMode === 'learning' ? `

⚠️ 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)}
` : ''}
`; document.body.appendChild(dialog); // 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: 'away', reason: 'manual' }) }) .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'}, body: JSON.stringify({ mode: 'home', reason: 'manual' }) }) .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 = anomaly.type === 'unknown_ble' ? 'Unknown Device Detected' : 'Motion Detected'; 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.dataset.anomalyId = anomaly.id; // Play alert sound playAlertSound(); } function hideAlertBanner() { if (_alertBanner) { _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); } 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'); } }) .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); } } // ── security card (sidebar) ───────────────────────────────────────────────────── function openSecurityCard() { // If panels framework is available, open as a sidebar panel if (window.SpaxelPanels) { SpaxelPanels.openSidebar({ title: 'Security Mode', content: '
', width: '350px', onOpen: function() { renderSecurityCard(); } }); } else { // Fallback: create as standalone modal createSecurityCardModal(); } } function renderSecurityCard() { const container = document.getElementById('security-card-content'); if (!container) return; const isArmed = _securityMode === 'armed' || _securityMode === 'alert'; const isLearning = _securityMode === 'learning'; container.innerHTML = `
${getModeIcon()}
${getModeText()}
${renderLearningProgress()}
Last 24h ${_anomalyCount24h}
Last Event ${_lastAnomaly ? formatTimeAgo(_lastAnomaly.timestamp) : 'None'}
${isArmed ? ` ` : ` `}
${renderRecentAnomalies()} `; } function getModeIcon() { switch (_securityMode) { case 'armed': return '🔴'; case 'alert': return '🚨'; case 'ready': return '🛡️'; case 'learning': return '📚'; default: return '🛡️'; } } function getModeText() { switch (_securityMode) { case 'armed': return 'ARMED'; case 'alert': return 'ALERT'; case 'ready': return 'READY'; case 'learning': return 'LEARNING'; default: return 'DISARMED'; } } function renderLearningProgress() { if (_securityMode !== 'learning') return ''; const progress = Math.round(_learningProgress * 100); const days = Math.ceil((1 - _learningProgress) * 7); return `
Learning Progress ${progress}%
`; } function renderRecentAnomalies() { // This would fetch recent anomalies from the API return `
Recent Anomalies
No recent anomalies
`; } // ── anomaly timeline ───────────────────────────────────────────────────────── function openAnomalyTimeline() { // If panels framework is available, open as panel if (window.SpaxelPanels) { SpaxelPanels.openSidebar({ title: 'Anomaly Timeline', content: '
', width: '450px', onOpen: function() { renderAnomalyTimeline(); } }); } } function renderAnomalyTimeline() { const container = document.getElementById('anomaly-timeline-content'); if (!container) return; container.innerHTML = `
Loading anomalies...
`; // Load anomalies loadAnomalies('24h'); // Attach filter listener const filter = container.querySelector('#anomaly-time-filter'); if (filter) { filter.addEventListener('change', function() { loadAnomalies(this.value); }); } } function loadAnomalies(timeFilter) { const listContainer = document.getElementById('anomaly-timeline-list'); if (!listContainer) return; let url = API.anomalyHistory + '?limit=50'; if (timeFilter && timeFilter !== 'all') { const since = Date.now() - parseTimeFilter(timeFilter); url += '&since=' + Math.floor(since / 1000); } fetch(url) .then(res => res.json()) .then(anomalies => { renderAnomaliesList(anomalies); }) .catch(err => { console.error('[SecurityPanel] Failed to load anomalies:', err); listContainer.innerHTML = `
Failed to load anomalies
`; }); } function renderAnomaliesList(anomalies) { const listContainer = document.getElementById('anomaly-timeline-list'); if (!listContainer) return; if (!anomalies || anomalies.length === 0) { listContainer.innerHTML = `
📭

No Anomalies

When anomalies are detected, they will appear here.

`; return; } let html = ''; anomalies.forEach(function(anomaly) { const severityClass = getSeverityClass(anomaly); const icon = getAnomalyIcon(anomaly); const time = formatTimeAgo(anomaly.timestamp); html += `
${icon}
${anomaly.description}
${anomaly.zone_name ? `${anomaly.zone_name} • ` : ''} ${time} ${anomaly.score ? `Score: ${Math.round(anomaly.score * 100)}%` : ''}
${!anomaly.acknowledged ? ` ` : `
Acknowledged
`}
`; }); listContainer.innerHTML = html; } function getSeverityClass(anomaly) { const score = anomaly.score || 0; if (score >= 0.85) return 'severity-critical'; if (score >= 0.6) return 'severity-warning'; return 'severity-info'; } function getAnomalyIcon(anomaly) { switch (anomaly.type) { case 'unknown_ble': return '📱'; case 'motion_during_away': return '🏃'; case 'unusual_hour': return '🕐'; case 'unusual_dwell': return '⏱️'; default: return '⚠️'; } } function acknowledgeFromTimeline(anomalyId) { acknowledgeAnomaly(anomalyId).then(() => { // Refresh the list const filter = document.getElementById('anomaly-time-filter'); loadAnomalies(filter ? filter.value : '24h'); }); } // ── WebSocket message handling ─────────────────────────────────────────────── function handleWebSocketMessage(msg) { switch (msg.type) { case 'system_mode_change': if (msg.data) { updateSecurityStatus({ security_mode: msg.data.new_mode === 'away' }); } break; case 'anomaly_detected': if (msg.data && _securityMode === 'armed') { showAlertBanner(msg.data); } break; } } // ── helpers ───────────────────────────────────────────────────────────────── function formatTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; if (diff < 60000) { const mins = Math.floor(diff / 60000); return mins + ' min' + (mins !== 1 ? 's' : '') + ' ago'; } else if (diff < 3600000) { 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 parseTimeFilter(filter) { // Convert "24h", "7d", "30d" to milliseconds const match = filter.match(/^(\d+)([hd])$/); if (match) { const value = parseInt(match[1]); const unit = match[2]; if (unit === 'h') return value * 3600000; if (unit === 'd') return value * 86400000; } return 86400000; // Default to 24 hours } // ── public API ────────────────────────────────────────────────────────────── window.SecurityPanel = { init: init, arm: arm, disarm: disarm, openSecurityDialog: openSecurityDialog, closeSecurityDialog: closeSecurityDialog, openSecurityCard: openSecurityCard, openAnomalyTimeline: openAnomalyTimeline, acknowledgeFromTimeline: acknowledgeFromTimeline, acknowledgeAnomaly: acknowledgeAnomaly, getSecurityMode: function() { return _securityMode; }, isLearning: function() { return _securityMode === 'learning'; }, isReady: function() { return _securityMode === 'ready'; }, setOnModeChange: function(cb) { _onModeChange = cb; } }; // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();