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:
jedarden 2026-04-25 09:03:43 -04:00
parent d14e8e9ee6
commit c0416fee6c
7 changed files with 744 additions and 68 deletions

View file

@ -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 {

View file

@ -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);

View file

@ -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">&times;</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; },

View file

@ -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)
})

View file

@ -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)

View file

@ -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{

View file

@ -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()