437 lines
17 KiB
JavaScript
437 lines
17 KiB
JavaScript
/**
|
|
* Spaxel Anomaly Detection UI
|
|
*
|
|
* Handles: alarm overlay, acknowledgement flow, feedback form,
|
|
* zone pulsing indication, and anomaly history display.
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ── module state ──────────────────────────────────────────────────────────
|
|
let _activeAnomalies = []; // Unacknowledged anomalies
|
|
let _anomalyHistory = []; // Recent anomaly history
|
|
let _learningProgress = 0; // 0.0 - 1.0
|
|
let _modelReady = false;
|
|
let _securityMode = false;
|
|
|
|
// DOM elements (lazy-initialized)
|
|
let _overlayEl = null;
|
|
let _bannerEl = null;
|
|
let _feedbackModalEl = null;
|
|
|
|
// Callbacks
|
|
let _onAcknowledge = null;
|
|
let _onViewIn3D = null;
|
|
|
|
// ── initialization ────────────────────────────────────────────────────────
|
|
|
|
function init() {
|
|
// Create overlay elements if not present
|
|
ensureOverlayElements();
|
|
ensureFeedbackModal();
|
|
|
|
// Start polling for anomaly status
|
|
startPolling();
|
|
|
|
console.log('[Anomaly] Module initialized');
|
|
}
|
|
|
|
function ensureOverlayElements() {
|
|
// Check if overlay already exists
|
|
_overlayEl = document.getElementById('anomaly-overlay');
|
|
if (_overlayEl) {
|
|
_bannerEl = document.getElementById('anomaly-banner');
|
|
return;
|
|
}
|
|
|
|
// Create overlay structure
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'anomaly-overlay';
|
|
overlay.className = 'anomaly-overlay hidden';
|
|
overlay.innerHTML = `
|
|
<div id="anomaly-banner" class="anomaly-banner">
|
|
<div class="anomaly-icon">
|
|
<svg viewBox="0 0 24 24" width="48" height="48">
|
|
<path fill="currentColor" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16"/>
|
|
</svg>
|
|
</div>
|
|
<div class="anomaly-content">
|
|
<div class="anomaly-title">Anomaly Detected</div>
|
|
<div id="anomaly-description" class="anomaly-description"></div>
|
|
<div id="anomaly-meta" class="anomaly-meta"></div>
|
|
</div>
|
|
<div class="anomaly-actions">
|
|
<button id="anomaly-ack-btn" class="anomaly-btn ack">Acknowledge</button>
|
|
<button id="anomaly-view-btn" class="anomaly-btn view">View in 3D</button>
|
|
<button id="anomaly-dismiss-btn" class="anomaly-btn dismiss">Dismiss</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
_overlayEl = overlay;
|
|
_bannerEl = document.getElementById('anomaly-banner');
|
|
|
|
// Add event listeners
|
|
document.getElementById('anomaly-ack-btn').addEventListener('click', handleAcknowledge);
|
|
document.getElementById('anomaly-view-btn').addEventListener('click', handleViewIn3D);
|
|
document.getElementById('anomaly-dismiss-btn').addEventListener('click', handleDismiss);
|
|
}
|
|
|
|
function ensureFeedbackModal() {
|
|
_feedbackModalEl = document.getElementById('anomaly-feedback-modal');
|
|
if (_feedbackModalEl) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'anomaly-feedback-modal';
|
|
modal.className = 'anomaly-feedback-modal hidden';
|
|
modal.innerHTML = `
|
|
<div class="modal-backdrop"></div>
|
|
<div class="modal-content">
|
|
<h3>What was this?</h3>
|
|
<p id="feedback-anomaly-desc" class="feedback-anomaly-desc"></p>
|
|
<div class="feedback-options">
|
|
<button class="feedback-btn expected" data-feedback="expected">
|
|
<span class="icon">✓</span>
|
|
<span class="label">Expected / Known Event</span>
|
|
<span class="desc">I was expecting this activity</span>
|
|
</button>
|
|
<button class="feedback-btn intrusion" data-feedback="intrusion">
|
|
<span class="icon">⚠</span>
|
|
<span class="label">Genuine Intrusion</span>
|
|
<span class="desc">This was unauthorized access</span>
|
|
</button>
|
|
<button class="feedback-btn false-alarm" data-feedback="false_alarm">
|
|
<span class="icon">✕</span>
|
|
<span class="label">False Alarm</span>
|
|
<span class="desc">This detection was incorrect</span>
|
|
</button>
|
|
</div>
|
|
<div class="feedback-notes">
|
|
<textarea id="feedback-notes-input" placeholder="Additional notes (optional)"></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button id="feedback-cancel-btn" class="modal-btn cancel">Cancel</button>
|
|
<button id="feedback-submit-btn" class="modal-btn submit" disabled>Submit Feedback</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
_feedbackModalEl = modal;
|
|
|
|
// Event listeners
|
|
modal.querySelector('.modal-backdrop').addEventListener('click', hideFeedbackModal);
|
|
document.getElementById('feedback-cancel-btn').addEventListener('click', hideFeedbackModal);
|
|
document.getElementById('feedback-submit-btn').addEventListener('click', submitFeedback);
|
|
|
|
// Option selection
|
|
modal.querySelectorAll('.feedback-btn').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
modal.querySelectorAll('.feedback-btn').forEach(b => b.classList.remove('selected'));
|
|
this.classList.add('selected');
|
|
document.getElementById('feedback-submit-btn').disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── polling ────────────────────────────────────────────────────────────────
|
|
|
|
function startPolling() {
|
|
fetchAnomalyStatus();
|
|
setInterval(fetchAnomalyStatus, 5000); // Poll every 5 seconds
|
|
}
|
|
|
|
function fetchAnomalyStatus() {
|
|
fetch('/api/anomalies/active')
|
|
.then(res => res.json())
|
|
.then(anomalies => {
|
|
handleAnomalyUpdate(anomalies || []);
|
|
})
|
|
.catch(err => console.error('[Anomaly] Failed to fetch status:', err));
|
|
|
|
fetch('/api/anomalies/learning')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
_learningProgress = data.progress || 0;
|
|
_modelReady = data.model_ready || false;
|
|
updateLearningBanner();
|
|
})
|
|
.catch(err => console.error('[Anomaly] Failed to fetch learning status:', err));
|
|
}
|
|
|
|
// ── anomaly handling ────────────────────────────────────────────────────────
|
|
|
|
function handleAnomalyUpdate(anomalies) {
|
|
const prevCount = _activeAnomalies.length;
|
|
_activeAnomalies = anomalies;
|
|
|
|
if (anomalies.length > 0) {
|
|
// Show overlay with first unacknowledged anomaly
|
|
showAnomalyOverlay(anomalies[0]);
|
|
} else if (prevCount > 0) {
|
|
// All anomalies acknowledged/cleared
|
|
hideAnomalyOverlay();
|
|
}
|
|
|
|
// Update zone pulsing in 3D view
|
|
if (window.Viz3D && Viz3D.setAnomalyZones) {
|
|
const zoneIDs = anomalies.map(a => a.zone_id).filter(z => z);
|
|
Viz3D.setAnomalyZones(zoneIDs);
|
|
}
|
|
}
|
|
|
|
function showAnomalyOverlay(anomaly) {
|
|
if (!_overlayEl) ensureOverlayElements();
|
|
|
|
const descEl = document.getElementById('anomaly-description');
|
|
const metaEl = document.getElementById('anomaly-meta');
|
|
|
|
descEl.textContent = anomaly.description || 'Unknown anomaly detected';
|
|
|
|
// Format metadata
|
|
let meta = '';
|
|
if (anomaly.zone_name) meta += `Zone: ${anomaly.zone_name} `;
|
|
if (anomaly.person_name) meta += `Person: ${anomaly.person_name} `;
|
|
if (anomaly.score) meta += `Score: ${(anomaly.score * 100).toFixed(0)}% `;
|
|
if (anomaly.timestamp) {
|
|
const ts = new Date(anomaly.timestamp);
|
|
meta += `Time: ${ts.toLocaleTimeString()}`;
|
|
}
|
|
metaEl.textContent = meta;
|
|
|
|
_overlayEl.classList.remove('hidden');
|
|
_overlayEl.dataset.anomalyId = anomaly.id;
|
|
}
|
|
|
|
function hideAnomalyOverlay() {
|
|
if (_overlayEl) {
|
|
_overlayEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function handleAcknowledge() {
|
|
if (!_overlayEl || !_overlayEl.dataset.anomalyId) return;
|
|
|
|
const anomalyId = _overlayEl.dataset.anomalyId;
|
|
const anomaly = _activeAnomalies.find(a => a.id === anomalyId);
|
|
|
|
if (!anomaly) return;
|
|
|
|
// Show feedback modal
|
|
showFeedbackModal(anomaly);
|
|
}
|
|
|
|
function handleViewIn3D() {
|
|
if (!_overlayEl || !_overlayEl.dataset.anomalyId) return;
|
|
|
|
const anomalyId = _overlayEl.dataset.anomalyId;
|
|
const anomaly = _activeAnomalies.find(a => a.id === anomalyId);
|
|
|
|
if (!anomaly) return;
|
|
|
|
if (_onViewIn3D) {
|
|
_onViewIn3D(anomaly);
|
|
} else if (window.Viz3D && Viz3D.focusOnZone && anomaly.zone_id) {
|
|
Viz3D.focusOnZone(anomaly.zone_id);
|
|
} else if (window.Viz3D && Viz3D.focusOnPosition && anomaly.position) {
|
|
Viz3D.focusOnPosition(anomaly.position.x, anomaly.position.y, anomaly.position.z);
|
|
}
|
|
}
|
|
|
|
function handleDismiss() {
|
|
// Dismiss just hides the overlay, doesn't acknowledge
|
|
hideAnomalyOverlay();
|
|
|
|
// Show next anomaly if any
|
|
if (_activeAnomalies.length > 1) {
|
|
setTimeout(() => {
|
|
if (_activeAnomalies.length > 0) {
|
|
showAnomalyOverlay(_activeAnomalies[0]);
|
|
}
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
// ── feedback modal ──────────────────────────────────────────────────────────
|
|
|
|
let _currentFeedbackAnomaly = null;
|
|
|
|
function showFeedbackModal(anomaly) {
|
|
_currentFeedbackAnomaly = anomaly;
|
|
|
|
const descEl = document.getElementById('feedback-anomaly-desc');
|
|
descEl.textContent = anomaly.description || 'Unknown anomaly';
|
|
|
|
// Reset selection
|
|
_feedbackModalEl.querySelectorAll('.feedback-btn').forEach(b => b.classList.remove('selected'));
|
|
document.getElementById('feedback-notes-input').value = '';
|
|
document.getElementById('feedback-submit-btn').disabled = true;
|
|
|
|
_feedbackModalEl.classList.remove('hidden');
|
|
}
|
|
|
|
function hideFeedbackModal() {
|
|
if (_feedbackModalEl) {
|
|
_feedbackModalEl.classList.add('hidden');
|
|
}
|
|
_currentFeedbackAnomaly = null;
|
|
}
|
|
|
|
function submitFeedback() {
|
|
if (!_currentFeedbackAnomaly) return;
|
|
|
|
const selectedBtn = _feedbackModalEl.querySelector('.feedback-btn.selected');
|
|
if (!selectedBtn) return;
|
|
|
|
const feedback = selectedBtn.dataset.feedback;
|
|
const notes = document.getElementById('feedback-notes-input').value;
|
|
|
|
const anomalyId = _currentFeedbackAnomaly.id;
|
|
|
|
fetch(`/api/anomalies/${anomalyId}/acknowledge`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
feedback: feedback,
|
|
notes: notes,
|
|
acknowledged_by: 'dashboard_user'
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log('[Anomaly] Feedback submitted:', data);
|
|
hideFeedbackModal();
|
|
hideAnomalyOverlay();
|
|
|
|
// Show toast
|
|
if (window.SpaxelApp && SpaxelApp.showToast) {
|
|
SpaxelApp.showToast('Feedback recorded. Thank you!', 'success');
|
|
}
|
|
|
|
// Refresh anomaly list
|
|
fetchAnomalyStatus();
|
|
})
|
|
.catch(err => {
|
|
console.error('[Anomaly] Failed to submit feedback:', err);
|
|
if (window.SpaxelApp && SpaxelApp.showToast) {
|
|
SpaxelApp.showToast('Failed to submit feedback', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── learning banner ─────────────────────────────────────────────────────────
|
|
|
|
function updateLearningBanner() {
|
|
const banner = document.getElementById('anomaly-learning-banner');
|
|
if (!banner) return;
|
|
|
|
if (_modelReady) {
|
|
banner.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
banner.classList.remove('hidden');
|
|
const progressEl = banner.querySelector('.learning-progress');
|
|
const daysEl = banner.querySelector('.days-remaining');
|
|
|
|
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...';
|
|
}
|
|
}
|
|
|
|
// ── WebSocket message handling ───────────────────────────────────────────────
|
|
|
|
function handleWebSocketMessage(msg) {
|
|
switch (msg.type) {
|
|
case 'anomaly_detected':
|
|
// New anomaly detected
|
|
if (msg.data) {
|
|
_activeAnomalies.unshift(msg.data);
|
|
showAnomalyOverlay(msg.data);
|
|
|
|
// Play alert sound in security mode
|
|
if (_securityMode) {
|
|
playAlertSound();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'system_mode_change':
|
|
if (msg.data && msg.data.new_mode) {
|
|
_securityMode = msg.data.new_mode === 'away';
|
|
updateSecurityModeIndicator();
|
|
}
|
|
break;
|
|
|
|
case 'anomaly_cleared':
|
|
// Anomaly was acknowledged/cleared
|
|
if (msg.data && msg.data.id) {
|
|
_activeAnomalies = _activeAnomalies.filter(a => a.id !== msg.data.id);
|
|
if (_activeAnomalies.length > 0) {
|
|
showAnomalyOverlay(_activeAnomalies[0]);
|
|
} else {
|
|
hideAnomalyOverlay();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function updateSecurityModeIndicator() {
|
|
const indicator = document.getElementById('security-mode-indicator');
|
|
if (!indicator) return;
|
|
|
|
if (_securityMode) {
|
|
indicator.classList.add('active');
|
|
indicator.textContent = 'SECURITY MODE';
|
|
} else {
|
|
indicator.classList.remove('active');
|
|
indicator.textContent = '';
|
|
}
|
|
}
|
|
|
|
function playAlertSound() {
|
|
// Create a simple alert tone
|
|
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 = 'sine';
|
|
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.3, 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('[Anomaly] Could not play alert sound:', e);
|
|
}
|
|
}
|
|
|
|
// ── public API ──────────────────────────────────────────────────────────────
|
|
|
|
window.AnomalyUI = {
|
|
init: init,
|
|
handleWebSocketMessage: handleWebSocketMessage,
|
|
getActiveAnomalies: function() { return _activeAnomalies; },
|
|
isSecurityMode: function() { return _securityMode; },
|
|
isModelReady: function() { return _modelReady; },
|
|
getLearningProgress: function() { return _learningProgress; },
|
|
setOnAcknowledge: function(cb) { _onAcknowledge = cb; },
|
|
setOnViewIn3D: function(cb) { _onViewIn3D = cb; },
|
|
showAnomalyOverlay: showAnomalyOverlay,
|
|
hideAnomalyOverlay: hideAnomalyOverlay,
|
|
refresh: fetchAnomalyStatus
|
|
};
|
|
|
|
})();
|