feat(dashboard): anomaly detection & security mode UI with WS consistency fix
- Add security status card with arm/disarm dialog, DISARMED/LEARNING/ARMED/ALERT badge, learning progress bar (N of 7 days), and last-anomaly summary line - Add full-width alert banner with acknowledge button for armed-mode anomalies; acknowledged alerts disappear from banner but remain in history - Add anomaly timeline panel (24h) with severity scores and timeline navigation - Fix WS broadcast field names to match AnomalyEvent JSON/REST API: anomaly_type→type, timestamp_ms→RFC3339 timestamp so JS handles both WS pushes and polled history uniformly - Fix formatTimeAgo() to parse RFC3339 string timestamps in addition to Unix-ms - Fix fetchAnomalyCount() to use /api/anomalies?since=24h (structured response) instead of /api/anomalies/history (returns plain array) - Add security-card detail area styling to anomaly.css - Add BlobIdentityProvider wiring in zones API for people resolution in zone responses - Add linkweather diagnostic engine tests (Rules 1-5 + helpers) All go test ./... pass; go vet ./... clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d14e8e9ee6
commit
c0416fee6c
7 changed files with 744 additions and 68 deletions
|
|
@ -454,6 +454,209 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Security Card Detail Area ─────────────────────────────────────────────────────── */
|
||||
|
||||
.security-status-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-1) 10px;
|
||||
border-radius: var(--radius-modal);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--slate-5);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.security-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-150);
|
||||
}
|
||||
|
||||
.security-card-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.security-history-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-half);
|
||||
border-radius: var(--radius-control);
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.security-history-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.security-learning-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-150);
|
||||
}
|
||||
|
||||
.security-learning-inline.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.security-progress-wrap {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.security-progress-fill {
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.security-progress-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.security-last-anomaly {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.security-last-anomaly.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Anomaly Timeline Panel ──────────────────────────────────────────────────────── */
|
||||
|
||||
.anomaly-timeline-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -380px;
|
||||
width: 360px;
|
||||
height: 100vh;
|
||||
z-index: 1800;
|
||||
transition: right 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.anomaly-timeline-panel.open {
|
||||
right: 0;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.anomaly-timeline-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-card);
|
||||
border-left: 1px solid var(--slate-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 20px var(--shadow);
|
||||
}
|
||||
|
||||
.anomaly-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--slate-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anomaly-timeline-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.anomaly-timeline-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.anomaly-timeline-view-all {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--blue-10);
|
||||
background: none;
|
||||
border: 1px solid var(--blue-10);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 2px var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.anomaly-timeline-view-all:hover {
|
||||
background: var(--blue-border);
|
||||
}
|
||||
|
||||
.anomaly-timeline-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-muted);
|
||||
padding: 0 var(--space-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.anomaly-timeline-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.anomaly-timeline-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.anomaly-timeline-loading,
|
||||
.anomaly-timeline-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.anomaly-history-item--acked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.anomaly-history-view-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-half);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.anomaly-history-view-btn:hover {
|
||||
color: var(--blue-10);
|
||||
}
|
||||
|
||||
/* ── Alert Banner ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
#alert-banner {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,13 @@
|
|||
if (poorQualityLinks.length === 0) {
|
||||
// Quality recovered - clear any active prompt and tracking state
|
||||
if (qualityPromptActive || qualityPromptLinkID) {
|
||||
// Clear dismiss-for-today so the prompt can re-appear if condition reoccurs
|
||||
if (qualityPromptLinkID) {
|
||||
const today = new Date().toDateString();
|
||||
const dismissKey = `${qualityPromptLinkID}_${today}`;
|
||||
dismissedQualityPrompts.delete(dismissKey);
|
||||
}
|
||||
dismissQualityPrompt();
|
||||
// Clear tracking state to allow re-arming if condition reoccurs
|
||||
qualityPromptLinkID = null;
|
||||
qualityPromptStartTime = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -86,16 +89,19 @@
|
|||
* Show a quality degradation prompt card
|
||||
*/
|
||||
function showQualityPrompt(link) {
|
||||
// Use consistent linkID computation (matches monitorLinkQuality)
|
||||
const linkID = link.link_id || (link.node_mac + ':' + link.peer_mac);
|
||||
|
||||
// Check if already dismissed today
|
||||
const today = new Date().toDateString();
|
||||
const dismissKey = `${link.link_id}_${today}`;
|
||||
const dismissKey = `${linkID}_${today}`;
|
||||
if (dismissedQualityPrompts.has(dismissKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
qualityPromptActive = true;
|
||||
|
||||
const linkName = link.name || formatLinkID(link.link_id);
|
||||
const linkName = link.name || formatLinkID(linkID);
|
||||
const qualityPercent = Math.round((link.composite_score || link.quality || 0) * 100);
|
||||
|
||||
// Remove existing prompt if present
|
||||
|
|
@ -118,7 +124,7 @@
|
|||
<p class="quality-prompt-detail">This link is experiencing degraded performance, which may affect detection accuracy in this area.</p>
|
||||
</div>
|
||||
<div class="quality-prompt-actions">
|
||||
<button class="quality-prompt-btn quality-prompt-diagnose" onclick="Proactive.diagnoseLink('${link.link_id}')">
|
||||
<button class="quality-prompt-btn quality-prompt-diagnose" onclick="Proactive.diagnoseLink('${linkID}')">
|
||||
<span class="btn-icon">🔍</span> Diagnose
|
||||
</button>
|
||||
<button class="quality-prompt-btn quality-prompt-dismiss" onclick="Proactive.dismissQualityPromptForToday()">
|
||||
|
|
@ -131,7 +137,7 @@
|
|||
document.body.appendChild(prompt);
|
||||
|
||||
// Highlight the 3D link line if available
|
||||
highlightLinkIn3D(link.link_id);
|
||||
highlightLinkIn3D(linkID);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -179,7 +185,10 @@
|
|||
try {
|
||||
const saved = localStorage.getItem('spaxel_dismissed_quality_prompts');
|
||||
if (saved) {
|
||||
dismissedQualityPrompts = new Set(saved.split(',').filter(id => id));
|
||||
const today = new Date().toDateString();
|
||||
const all = saved.split(',').filter(id => id);
|
||||
// Only keep today's entries to prevent stale data buildup
|
||||
dismissedQualityPrompts = new Set(all.filter(key => key.endsWith(today)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load dismissed prompts:', e);
|
||||
|
|
@ -315,8 +324,7 @@
|
|||
*/
|
||||
function diagnoseLink(linkID) {
|
||||
// Fetch diagnostic results from API
|
||||
// Using the correct endpoint: /api/links/{linkID}/diagnostics
|
||||
fetch(`/api/links/${encodeURIComponent(linkID)}/diagnostics`)
|
||||
fetch(`/api/diagnostics/link/${encodeURIComponent(linkID)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
showDiagnosticResults(linkID, data);
|
||||
|
|
|
|||
|
|
@ -70,23 +70,39 @@
|
|||
indicator.id = 'security-status-indicator';
|
||||
indicator.className = 'security-status-indicator mode-disarmed';
|
||||
indicator.innerHTML = `
|
||||
<span class="security-icon">🛡️</span>
|
||||
<span class="security-text">Disarmed</span>
|
||||
<button class="security-toggle-btn" aria-label="Toggle security mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M12 15.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm.5 0a1 1 0 1 0-2 0 1 1 0 0 0 2 0zM12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8-8zm0-14a6 6 0 1 0-6 6 6 6 0 0 0 6 6zm0 10a4 4 0 1 1-4 4 4 4 0 0 1 4-4zm0-6a2 2 0 1 0-2 2 2 2 0 0 0 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="security-card-main">
|
||||
<span class="security-icon">🛡️</span>
|
||||
<span class="security-text">DISARMED</span>
|
||||
<button class="security-toggle-btn" aria-label="Toggle security mode" title="Arm / Disarm security mode">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="security-history-btn" aria-label="View anomaly history" title="View anomaly history">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="security-card-detail">
|
||||
<div class="security-learning-inline hidden" id="security-learning-inline">
|
||||
<div class="security-progress-wrap">
|
||||
<div class="security-progress-fill" id="security-progress-fill"></div>
|
||||
</div>
|
||||
<span class="security-progress-label" id="security-progress-label">0 of 7 days complete</span>
|
||||
</div>
|
||||
<div class="security-last-anomaly hidden" id="security-last-anomaly-line"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(indicator);
|
||||
_statusIndicator = indicator;
|
||||
|
||||
// Add click handler for mode toggle
|
||||
const toggleBtn = indicator.querySelector('.security-toggle-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', openSecurityDialog);
|
||||
}
|
||||
if (toggleBtn) toggleBtn.addEventListener('click', openSecurityDialog);
|
||||
|
||||
const historyBtn = indicator.querySelector('.security-history-btn');
|
||||
if (historyBtn) historyBtn.addEventListener('click', openAnomalyTimeline);
|
||||
}
|
||||
|
||||
// ── polling ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -110,16 +126,16 @@
|
|||
}
|
||||
|
||||
function fetchAnomalyCount() {
|
||||
fetch(API.anomalyHistory + '?since=24h&limit=1')
|
||||
// /api/anomalies returns {active, history, since} so we can get both count and last event
|
||||
fetch(API.anomalies + '?since=24h')
|
||||
.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();
|
||||
const history = data.history || [];
|
||||
_anomalyCount24h = history.length;
|
||||
if (history.length > 0) {
|
||||
_lastAnomaly = history[0];
|
||||
}
|
||||
updateStatusIndicator();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[SecurityPanel] Failed to fetch anomaly count:', err);
|
||||
|
|
@ -198,11 +214,13 @@
|
|||
icon = '🛡️';
|
||||
modeClass = 'mode-ready';
|
||||
break;
|
||||
case 'learning':
|
||||
text = 'LEARNING';
|
||||
case 'learning': {
|
||||
const daysLeft = Math.ceil((1 - _learningProgress) * 7);
|
||||
text = daysLeft > 0 ? `LEARNING (${daysLeft}d left)` : 'LEARNING';
|
||||
icon = '📚';
|
||||
modeClass = 'mode-learning';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
text = 'DISARMED';
|
||||
icon = '🛡️';
|
||||
|
|
@ -216,6 +234,36 @@
|
|||
// Update mode class
|
||||
_statusIndicator.classList.remove('mode-disarmed', 'mode-learning', 'mode-armed', 'mode-alert', 'mode-ready');
|
||||
_statusIndicator.classList.add(modeClass);
|
||||
|
||||
// Update learning progress bar inline
|
||||
const learningInline = document.getElementById('security-learning-inline');
|
||||
const progressFill = document.getElementById('security-progress-fill');
|
||||
const progressLabel = document.getElementById('security-progress-label');
|
||||
if (learningInline) {
|
||||
if (!_modelReady) {
|
||||
learningInline.classList.remove('hidden');
|
||||
const daysComplete = Math.floor(_learningProgress * 7);
|
||||
if (progressFill) progressFill.style.width = (_learningProgress * 100) + '%';
|
||||
if (progressLabel) progressLabel.textContent = `${daysComplete} of 7 days complete`;
|
||||
} else {
|
||||
learningInline.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update last anomaly line
|
||||
const lastAnomalyLine = document.getElementById('security-last-anomaly-line');
|
||||
if (lastAnomalyLine) {
|
||||
if (_lastAnomaly) {
|
||||
lastAnomalyLine.classList.remove('hidden');
|
||||
const timeAgo = formatTimeAgo(_lastAnomaly.timestamp);
|
||||
const zone = _lastAnomaly.zone_name || 'unknown zone';
|
||||
const ts = _lastAnomaly.timestamp ? new Date(_lastAnomaly.timestamp) : null;
|
||||
const timeStr = ts ? ts.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : '';
|
||||
lastAnomalyLine.textContent = `Last: ${timeAgo} — ${zone}${timeStr ? ' at ' + timeStr : ''}`;
|
||||
} else {
|
||||
lastAnomalyLine.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateLearningProgress() {
|
||||
|
|
@ -490,11 +538,7 @@
|
|||
if (viewBtn) {
|
||||
viewBtn.addEventListener('click', function() {
|
||||
hideAlertBanner();
|
||||
if (window.TimelineView) {
|
||||
TimelineView.show();
|
||||
} else if (window.SpaxelRouter) {
|
||||
SpaxelRouter.setMode('timeline');
|
||||
}
|
||||
openAnomalyTimeline();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -557,11 +601,159 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── anomaly timeline panel ──────────────────────────────────────────────────
|
||||
|
||||
function openAnomalyTimeline() {
|
||||
ensureAnomalyTimelinePanel();
|
||||
const panel = document.getElementById('anomaly-timeline-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('open');
|
||||
fetchAndRenderAnomalyHistory();
|
||||
}
|
||||
|
||||
function closeAnomalyTimeline() {
|
||||
const panel = document.getElementById('anomaly-timeline-panel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
}
|
||||
|
||||
function ensureAnomalyTimelinePanel() {
|
||||
if (document.getElementById('anomaly-timeline-panel')) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'anomaly-timeline-panel';
|
||||
panel.className = 'anomaly-timeline-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-label', 'Anomaly history');
|
||||
panel.innerHTML = `
|
||||
<div class="anomaly-timeline-inner">
|
||||
<div class="anomaly-timeline-header">
|
||||
<span class="anomaly-timeline-title">Anomaly History (24h)</span>
|
||||
<div class="anomaly-timeline-header-actions">
|
||||
<button class="anomaly-timeline-view-all" id="anomaly-timeline-view-all" title="Open full timeline">
|
||||
View All
|
||||
</button>
|
||||
<button class="anomaly-timeline-close" id="anomaly-timeline-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anomaly-timeline-list" id="anomaly-timeline-list">
|
||||
<div class="anomaly-timeline-loading">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
document.getElementById('anomaly-timeline-close')
|
||||
.addEventListener('click', closeAnomalyTimeline);
|
||||
|
||||
document.getElementById('anomaly-timeline-view-all')
|
||||
.addEventListener('click', function() {
|
||||
closeAnomalyTimeline();
|
||||
if (window.TimelineView && TimelineView.show) {
|
||||
TimelineView.show();
|
||||
} else if (window.SpaxelRouter) {
|
||||
SpaxelRouter.setMode('timeline');
|
||||
}
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
panel.addEventListener('click', function(e) {
|
||||
if (e.target === panel) closeAnomalyTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAndRenderAnomalyHistory() {
|
||||
const list = document.getElementById('anomaly-timeline-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="anomaly-timeline-loading">Loading…</div>';
|
||||
|
||||
fetch('/api/anomalies?since=24h&limit=20')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const anomalies = data.history || data || [];
|
||||
renderAnomalyHistory(list, anomalies);
|
||||
})
|
||||
.catch(err => {
|
||||
list.innerHTML = '<div class="anomaly-timeline-empty">Failed to load history.</div>';
|
||||
console.error('[SecurityPanel] Failed to fetch anomaly history:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAnomalyHistory(container, anomalies) {
|
||||
if (!anomalies || anomalies.length === 0) {
|
||||
container.innerHTML = '<div class="anomaly-timeline-empty">No anomalies in the last 24 hours.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = anomalies.map(function(a) {
|
||||
const typeClass = (a.type || '').replace(/_/g, '-');
|
||||
const icon = getAnomalyIcon(a.type);
|
||||
const title = getAnomalyTitle(a);
|
||||
const timeAgo = formatTimeAgo(a.timestamp);
|
||||
const score = a.score || 0;
|
||||
const scoreClass = score >= 0.85 ? 'high' : score >= 0.6 ? 'medium' : 'low';
|
||||
const scorePct = Math.round(score * 100);
|
||||
const zone = a.zone_name || 'Unknown zone';
|
||||
const acknowledged = a.acknowledged ? ' anomaly-history-item--acked' : '';
|
||||
const feedbackHtml = a.feedback
|
||||
? `<span class="anomaly-history-feedback ${a.feedback.replace(/_/g, '-')}">${formatFeedback(a.feedback)}</span>`
|
||||
: '';
|
||||
|
||||
return `<div class="anomaly-history-item${acknowledged}" data-id="${a.id || ''}">
|
||||
<div class="anomaly-history-icon ${typeClass}">${icon}</div>
|
||||
<div class="anomaly-history-content">
|
||||
<div class="anomaly-history-title">${title}</div>
|
||||
<div class="anomaly-history-time">${zone} · ${timeAgo}</div>
|
||||
</div>
|
||||
<span class="anomaly-history-score ${scoreClass}">${scorePct}%</span>
|
||||
${feedbackHtml}
|
||||
<button class="anomaly-history-view-btn" data-id="${a.id || ''}" title="View in Timeline" aria-label="View in timeline">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.anomaly-history-view-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
closeAnomalyTimeline();
|
||||
if (window.TimelineView && TimelineView.show) {
|
||||
TimelineView.show();
|
||||
if (this.dataset.id && TimelineView.scrollToEvent) {
|
||||
TimelineView.scrollToEvent(this.dataset.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAnomalyIcon(type) {
|
||||
switch (type) {
|
||||
case 'unusual_hour': return '🕰️';
|
||||
case 'unknown_ble': return '📡';
|
||||
case 'motion_during_away': return '🚶';
|
||||
case 'unusual_dwell': return '⏱️';
|
||||
default: return '⚠️';
|
||||
}
|
||||
}
|
||||
|
||||
function formatFeedback(feedback) {
|
||||
switch (feedback) {
|
||||
case 'expected': return 'Expected';
|
||||
case 'intrusion': return 'Intrusion';
|
||||
case 'false_alarm': return 'False Alarm';
|
||||
default: return feedback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
// Accept Unix-ms numbers or ISO8601/RFC3339 strings
|
||||
const ts = typeof timestamp === 'number' ? timestamp : new Date(timestamp).getTime();
|
||||
if (isNaN(ts)) return 'unknown time';
|
||||
const diff = now - ts;
|
||||
|
||||
if (diff < 60000) {
|
||||
const secs = Math.floor(diff / 1000);
|
||||
|
|
@ -623,6 +815,8 @@
|
|||
openSecurityDialog: openSecurityDialog,
|
||||
closeSecurityDialog: closeSecurityDialog,
|
||||
acknowledgeAnomaly: acknowledgeAnomaly,
|
||||
openAnomalyTimeline: openAnomalyTimeline,
|
||||
closeAnomalyTimeline: closeAnomalyTimeline,
|
||||
getSecurityMode: function() { return _securityMode; },
|
||||
isLearning: function() { return _securityMode === 'learning' || !_modelReady; },
|
||||
isReady: function() { return _modelReady; },
|
||||
|
|
|
|||
|
|
@ -1425,23 +1425,26 @@ func main() {
|
|||
|
||||
// Set callback to broadcast anomalies to dashboard
|
||||
anomalyDetector.SetOnAnomaly(func(event events.AnomalyEvent) {
|
||||
// Broadcast as typed anomaly_detected for dashboard alert handling
|
||||
dashboardHub.BroadcastAnomaly(map[string]interface{}{
|
||||
"id": event.ID,
|
||||
"anomaly_type": event.Type,
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"severity": "warning",
|
||||
"timestamp_ms": event.Timestamp.UnixMilli(),
|
||||
})
|
||||
|
||||
// Also broadcast as alert for the alert banner
|
||||
// Use same field names as AnomalyEvent JSON / REST API so the frontend
|
||||
// can handle both WebSocket pushes and polled history uniformly.
|
||||
severity := "warning"
|
||||
if event.Score >= 0.85 {
|
||||
severity = "critical"
|
||||
}
|
||||
dashboardHub.BroadcastAnomaly(map[string]interface{}{
|
||||
"id": event.ID,
|
||||
"type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_name": event.PersonName,
|
||||
"severity": severity,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"acknowledged": false,
|
||||
})
|
||||
|
||||
// Also broadcast as alert for the alert banner
|
||||
dashboardHub.BroadcastAlert(event.ID, event.Timestamp, severity, event.Description, event.Acknowledged)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,21 @@ import (
|
|||
"github.com/spaxel/mothership/internal/zones"
|
||||
)
|
||||
|
||||
// BlobIdentityProvider resolves blob IDs to person labels.
|
||||
type BlobIdentityProvider interface {
|
||||
// PersonLabelForBlob returns the BLE-identified person label for a blob,
|
||||
// or an empty string if the blob is unidentified.
|
||||
PersonLabelForBlob(blobID int) string
|
||||
}
|
||||
|
||||
// ZonesHandler manages zones and portals via the zones.Manager.
|
||||
// Changes to zones and portals are immediately broadcast to dashboard clients
|
||||
// via the ZoneChangeBroadcaster, and also reflected in the next delta tick.
|
||||
type ZonesHandler struct {
|
||||
mu sync.RWMutex
|
||||
mgr *zones.Manager
|
||||
bc dashboard.ZoneChangeBroadcaster
|
||||
mu sync.RWMutex
|
||||
mgr *zones.Manager
|
||||
bc dashboard.ZoneChangeBroadcaster
|
||||
ident BlobIdentityProvider
|
||||
}
|
||||
|
||||
// zoneWithOcc extends a zone with current occupancy and people list for API responses.
|
||||
|
|
@ -93,6 +101,13 @@ func (h *ZonesHandler) SetZoneChangeBroadcaster(bc dashboard.ZoneChangeBroadcast
|
|||
h.bc = bc
|
||||
}
|
||||
|
||||
// SetBlobIdentityProvider sets the provider that resolves blob IDs to person labels.
|
||||
func (h *ZonesHandler) SetBlobIdentityProvider(p BlobIdentityProvider) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.ident = p
|
||||
}
|
||||
|
||||
// notifyZoneChange broadcasts a zone change event if a broadcaster is set.
|
||||
func (h *ZonesHandler) notifyZoneChange(action string, z *zones.Zone) {
|
||||
h.mu.RLock()
|
||||
|
|
@ -290,8 +305,13 @@ func (h *ZonesHandler) RegisterRoutes(r chi.Router) {
|
|||
func (h *ZonesHandler) toZoneResponse(z *zones.Zone) zoneWithOcc {
|
||||
occ := h.mgr.GetZoneOccupancy(z.ID)
|
||||
count := 0
|
||||
var people []string
|
||||
if occ != nil {
|
||||
count = occ.Count
|
||||
people = h.resolvePeople(occ.BlobIDs)
|
||||
}
|
||||
if people == nil {
|
||||
people = []string{}
|
||||
}
|
||||
return zoneWithOcc{
|
||||
ID: z.ID,
|
||||
|
|
@ -309,11 +329,33 @@ func (h *ZonesHandler) toZoneResponse(z *zones.Zone) zoneWithOcc {
|
|||
Enabled: z.Enabled,
|
||||
ZoneType: string(z.ZoneType),
|
||||
Occupancy: count,
|
||||
People: []string{},
|
||||
People: people,
|
||||
CreatedAt: z.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePeople maps blob IDs to deduplicated person labels.
|
||||
func (h *ZonesHandler) resolvePeople(blobIDs []int) []string {
|
||||
h.mu.RLock()
|
||||
ident := h.ident
|
||||
h.mu.RUnlock()
|
||||
|
||||
if ident == nil || len(blobIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var people []string
|
||||
for _, id := range blobIDs {
|
||||
label := ident.PersonLabelForBlob(id)
|
||||
if label != "" && !seen[label] {
|
||||
seen[label] = true
|
||||
people = append(people, label)
|
||||
}
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
// toPortalResponse converts a zones.Portal to the API response format.
|
||||
func toPortalResponse(p *zones.Portal) portalWithZones {
|
||||
return portalWithZones{
|
||||
|
|
@ -444,13 +486,10 @@ func (h *ZonesHandler) deleteZone(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// historyEntry represents an hourly occupancy bucket for the zone history API.
|
||||
type historyEntry struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Count int `json:"count"`
|
||||
People []string `json:"people"`
|
||||
}
|
||||
type historyEntry = zones.HistoryEntry
|
||||
|
||||
// getZoneHistory returns hourly occupancy history for a zone.
|
||||
// getZoneHistory returns hourly occupancy history for a zone by querying
|
||||
// crossing events from the zones manager's SQLite database.
|
||||
func (h *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
|
|
@ -467,15 +506,9 @@ func (h *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) {
|
|||
limit = 24 * 30
|
||||
}
|
||||
|
||||
// Generate synthetic history data (in real implementation, query from events)
|
||||
history := make([]historyEntry, limit)
|
||||
now := time.Now()
|
||||
for i := range history {
|
||||
history[i] = historyEntry{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Hour).UnixNano() / 1e6,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
}
|
||||
history := h.mgr.GetZoneHistory(id, limit)
|
||||
if history == nil {
|
||||
history = []historyEntry{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, history)
|
||||
|
|
|
|||
|
|
@ -707,6 +707,198 @@ func TestGetAllDiagnoses(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestGetDiagnosticFor tests point-in-time diagnostic lookup
|
||||
func TestGetDiagnosticFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
linkID string
|
||||
setupHistory func() []LinkHealthSnapshot
|
||||
wantRuleID string
|
||||
wantSeverity DiagnosisSeverity
|
||||
}{
|
||||
{
|
||||
name: "environmental_change_from_high_drift",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
now := time.Now()
|
||||
samples := make([]LinkHealthSnapshot, 15)
|
||||
for i := range samples {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 18.0,
|
||||
DriftRate: 0.08, // High drift
|
||||
}
|
||||
}
|
||||
return samples
|
||||
},
|
||||
wantRuleID: "environmental_change",
|
||||
wantSeverity: SeverityINFO,
|
||||
},
|
||||
{
|
||||
name: "wifi_congestion_from_low_packet_rate",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
now := time.Now()
|
||||
samples := make([]LinkHealthSnapshot, 15)
|
||||
for i := range samples {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 10.0, // Below 16 Hz threshold
|
||||
DriftRate: 0.02,
|
||||
}
|
||||
}
|
||||
return samples
|
||||
},
|
||||
wantRuleID: "wifi_congestion",
|
||||
wantSeverity: SeverityACTIONABLE,
|
||||
},
|
||||
{
|
||||
name: "metal_interference_from_phase_instability",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
now := time.Now()
|
||||
samples := make([]LinkHealthSnapshot, 15)
|
||||
for i := range samples {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.8, // Above 0.6 threshold
|
||||
PacketRate: 18.0,
|
||||
DriftRate: 0.02,
|
||||
}
|
||||
}
|
||||
return samples
|
||||
},
|
||||
wantRuleID: "metal_interference",
|
||||
wantSeverity: SeverityACTIONABLE,
|
||||
},
|
||||
{
|
||||
name: "periodic_interference_from_variance_spike",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
now := time.Now()
|
||||
samples := make([]LinkHealthSnapshot, 15)
|
||||
for i := range samples {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 18.0,
|
||||
DriftRate: 0.02,
|
||||
DeltaRMSVariance: 3.0, // Above 2.0 threshold
|
||||
}
|
||||
}
|
||||
return samples
|
||||
},
|
||||
wantRuleID: "periodic_interference",
|
||||
wantSeverity: SeverityWARNING,
|
||||
},
|
||||
{
|
||||
name: "no_issue_when_metrics_normal",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
now := time.Now()
|
||||
samples := make([]LinkHealthSnapshot, 15)
|
||||
for i := range samples {
|
||||
samples[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.8,
|
||||
PhaseStability: 0.2,
|
||||
PacketRate: 19.0,
|
||||
DriftRate: 0.01,
|
||||
}
|
||||
}
|
||||
return samples
|
||||
},
|
||||
wantRuleID: "no_issue_detected",
|
||||
wantSeverity: SeverityINFO,
|
||||
},
|
||||
{
|
||||
name: "insufficient_data_returns_generic",
|
||||
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
|
||||
setupHistory: func() []LinkHealthSnapshot {
|
||||
return []LinkHealthSnapshot{} // Empty history
|
||||
},
|
||||
wantRuleID: "insufficient_data",
|
||||
wantSeverity: SeverityINFO,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
engine := NewDiagnosticEngine(DiagnosticConfig{
|
||||
DiagnosticInterval: 15 * time.Minute,
|
||||
HistoryWindow: 1 * time.Hour,
|
||||
MinSamples: 10,
|
||||
})
|
||||
|
||||
history := tt.setupHistory()
|
||||
|
||||
engine.SetHealthHistoryAccessor(func(linkID string, window time.Duration) []LinkHealthSnapshot {
|
||||
return history
|
||||
})
|
||||
|
||||
diagnosis := engine.GetDiagnosticFor(tt.linkID, time.Now())
|
||||
|
||||
if diagnosis == nil {
|
||||
t.Fatal("Expected non-nil diagnosis")
|
||||
}
|
||||
if diagnosis.RuleID != tt.wantRuleID {
|
||||
t.Errorf("Expected RuleID %q, got %q", tt.wantRuleID, diagnosis.RuleID)
|
||||
}
|
||||
if diagnosis.Severity != tt.wantSeverity {
|
||||
t.Errorf("Expected Severity %s, got %s", tt.wantSeverity, diagnosis.Severity)
|
||||
}
|
||||
if diagnosis.Title == "" {
|
||||
t.Error("Expected non-empty Title")
|
||||
}
|
||||
if diagnosis.Detail == "" {
|
||||
t.Error("Expected non-empty Detail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDiagnosticFor_PriorityOrder tests that more severe issues take priority
|
||||
func TestGetDiagnosticFor_PriorityOrder(t *testing.T) {
|
||||
engine := NewDiagnosticEngine(DiagnosticConfig{
|
||||
DiagnosticInterval: 15 * time.Minute,
|
||||
HistoryWindow: 1 * time.Hour,
|
||||
MinSamples: 10,
|
||||
})
|
||||
|
||||
// Snapshot with both high drift AND low packet rate
|
||||
// Drift check comes first in GetDiagnosticFor, so environmental_change should win
|
||||
now := time.Now()
|
||||
history := make([]LinkHealthSnapshot, 15)
|
||||
for i := range history {
|
||||
history[i] = LinkHealthSnapshot{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Minute),
|
||||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 10.0, // Low
|
||||
DriftRate: 0.08, // High
|
||||
}
|
||||
}
|
||||
|
||||
engine.SetHealthHistoryAccessor(func(linkID string, window time.Duration) []LinkHealthSnapshot {
|
||||
return history
|
||||
})
|
||||
|
||||
diagnosis := engine.GetDiagnosticFor("AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", now)
|
||||
if diagnosis == nil {
|
||||
t.Fatal("Expected non-nil diagnosis")
|
||||
}
|
||||
// Environmental change is checked first (drift > 0.05)
|
||||
if diagnosis.RuleID != "environmental_change" {
|
||||
t.Errorf("Expected environmental_change (drift checked first), got %s", diagnosis.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiagnosticEngineStop tests that the engine stops cleanly
|
||||
func TestDiagnosticEngineStop(t *testing.T) {
|
||||
engine := NewDiagnosticEngine(DiagnosticConfig{
|
||||
|
|
|
|||
|
|
@ -1186,6 +1186,49 @@ func (m *Manager) IsReconciled() bool {
|
|||
return m.reconciled
|
||||
}
|
||||
|
||||
// HistoryEntry represents an hourly occupancy bucket for the zone history API.
|
||||
type HistoryEntry struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Count int `json:"count"`
|
||||
People []string `json:"people"`
|
||||
}
|
||||
|
||||
// GetZoneHistory returns hourly occupancy buckets for a zone by querying
|
||||
// crossing_events from SQLite. It computes net entry count per hour window.
|
||||
func (m *Manager) GetZoneHistory(zoneID string, hours int) []HistoryEntry {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
entries := make([]HistoryEntry, hours)
|
||||
|
||||
// Build hourly buckets from now backwards
|
||||
for i := 0; i < hours; i++ {
|
||||
bucketEnd := now.Add(-time.Duration(i) * time.Hour)
|
||||
bucketStart := bucketEnd.Add(-time.Hour)
|
||||
entries[i] = HistoryEntry{
|
||||
Timestamp: bucketEnd.UnixNano() / 1e6,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
}
|
||||
|
||||
// Query net crossings into this zone during this bucket
|
||||
var netIn int
|
||||
row := m.db.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN to_zone = ? THEN 1 ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN from_zone = ? THEN 1 ELSE 0 END), 0)
|
||||
FROM crossing_events
|
||||
WHERE timestamp >= ? AND timestamp < ?
|
||||
`, zoneID, zoneID, bucketStart.UnixMilli(), bucketEnd.UnixMilli())
|
||||
if err := row.Scan(&netIn); err == nil && netIn > 0 {
|
||||
entries[i].Count = netIn
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// GetOccupancyStatus returns the status map for all zones.
|
||||
func (m *Manager) GetOccupancyStatus() map[string]OccupancyStatus {
|
||||
m.mu.RLock()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue