From c0416fee6cf8e284029202335afd04ffc6a7ecd0 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sat, 25 Apr 2026 09:03:43 -0400 Subject: [PATCH] feat(dashboard): anomaly detection & security mode UI with WS consistency fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- dashboard/css/anomaly.css | 203 +++++++++++++++ dashboard/js/proactive.js | 28 +- dashboard/js/security-panel.js | 246 ++++++++++++++++-- mothership/cmd/mothership/main.go | 29 ++- mothership/internal/api/zones.go | 71 +++-- .../internal/diagnostics/linkweather_test.go | 192 ++++++++++++++ mothership/internal/zones/manager.go | 43 +++ 7 files changed, 744 insertions(+), 68 deletions(-) diff --git a/dashboard/css/anomaly.css b/dashboard/css/anomaly.css index f81c460..e4bc1ff 100644 --- a/dashboard/css/anomaly.css +++ b/dashboard/css/anomaly.css @@ -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 { diff --git a/dashboard/js/proactive.js b/dashboard/js/proactive.js index 8439d7f..43afe42 100644 --- a/dashboard/js/proactive.js +++ b/dashboard/js/proactive.js @@ -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 @@

This link is experiencing degraded performance, which may affect detection accuracy in this area.

- +
+ 🛡️ + 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); - } + 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 = ` +
+
+ Anomaly History (24h) +
+ + +
+
+
+
Loading…
+
+
+ `; + 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 = '
Loading…
'; + + 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 = '
Failed to load history.
'; + console.error('[SecurityPanel] Failed to fetch anomaly history:', err); + }); + } + + function renderAnomalyHistory(container, anomalies) { + if (!anomalies || anomalies.length === 0) { + container.innerHTML = '
No anomalies in the last 24 hours.
'; + 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 + ? `${formatFeedback(a.feedback)}` + : ''; + + return `
+
${icon}
+
+
${title}
+
${zone} · ${timeAgo}
+
+ ${scorePct}% + ${feedbackHtml} + +
`; + }).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; }, diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 3648bdb..48dfbf1 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -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) }) diff --git a/mothership/internal/api/zones.go b/mothership/internal/api/zones.go index 95d1deb..c564a7f 100644 --- a/mothership/internal/api/zones.go +++ b/mothership/internal/api/zones.go @@ -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) diff --git a/mothership/internal/diagnostics/linkweather_test.go b/mothership/internal/diagnostics/linkweather_test.go index 3a08c0b..c8b066d 100644 --- a/mothership/internal/diagnostics/linkweather_test.go +++ b/mothership/internal/diagnostics/linkweather_test.go @@ -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{ diff --git a/mothership/internal/zones/manager.go b/mothership/internal/zones/manager.go index 739c33b..c78da18 100644 --- a/mothership/internal/zones/manager.go +++ b/mothership/internal/zones/manager.go @@ -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()