diff --git a/dashboard/js/app.js b/dashboard/js/app.js index ab0018e..3b66e3d 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -52,7 +52,9 @@ // Diurnal learning tracking diurnalStatus: new Map(), // linkID -> { is_learning, progress, is_ready, days_remaining } diurnalPollTimer: null, - healthPollTimer: null + healthPollTimer: null, + // BLE device tracking + bleDevices: new Map() // MAC -> { mac, name, rssi, last_seen, label, blob_id } }; // ============================================ @@ -417,6 +419,126 @@ // ============================================ // Message Handling // ============================================ + + // Event handlers for new message types + + function handleEventMessage(msg) { + if (!msg.event) return; + + const event = msg.event; + console.log('[Spaxel] Event:', event.kind, 'in', event.zone, 'by', event.person_name || 'blob #' + event.blob_id); + + // Log to timeline + const timeStr = new Date(event.ts).toLocaleTimeString(); + let description = ''; + + if (event.kind === 'zone_entry') { + description = (event.person_name || 'Someone') + ' entered ' + event.zone; + } else if (event.kind === 'zone_exit') { + description = (event.person_name || 'Someone') + ' left ' + event.zone; + } else if (event.kind === 'portal_crossing') { + description = (event.person_name || 'Someone') + ' crossed portal in ' + event.zone; + } else if (event.kind === 'presence_transition') { + description = (event.person_name || 'Someone') + ' presence detected in ' + event.zone; + } else { + description = event.kind + ' in ' + event.zone; + } + + logTimelineEvent(event.kind, null, description + ' (' + timeStr + ')'); + + // Show toast for security-relevant events + if (event.kind === 'zone_entry' || event.kind === 'portal_crossing') { + showToast(description, 'info'); + } + } + + function handleAlertMessage(msg) { + if (!msg.alert) return; + + const alert = msg.alert; + console.log('[Spaxel] Alert:', alert.severity, alert.description); + + // Show toast notification + const toastType = alert.severity === 'critical' ? 'error' : 'warning'; + showToast(alert.description, toastType); + + // Log to timeline + const timeStr = new Date(alert.ts).toLocaleTimeString(); + logTimelineEvent('alert', null, '[' + alert.severity.toUpperCase() + '] ' + alert.description + ' (' + timeStr + ')'); + + // Could trigger UI alert state here (e.g., show alert banner) + if (window.showAlertBanner) { + window.showAlertBanner(alert); + } + } + + function handleBLEScanMessage(msg) { + if (!msg.devices || !Array.isArray(msg.devices)) return; + + console.log('[Spaxel] BLE scan: ' + msg.devices.length + ' devices'); + + // Update BLE device list state + if (!state.bleDevices) { + state.bleDevices = new Map(); + } + + // Clear previous entries and add current devices + state.bleDevices.clear(); + msg.devices.forEach(function (device) { + state.bleDevices.set(device.mac || device.addr, { + mac: device.mac || device.addr, + name: device.name || device.device_name || 'Unknown', + rssi: device.rssi || device.rssi_dbm || 0, + last_seen: device.last_seen || Date.now(), + label: device.label || '', + blob_id: device.blob_id || null + }); + }); + + // Update UI if BLE panel exists + if (window.BLEPanel && window.BLEPanel.updateDevices) { + window.BLEPanel.updateDevices(msg.devices); + } + } + + function handleTriggerStateMessage(msg) { + if (!msg.trigger) return; + + const trigger = msg.trigger; + console.log('[Spaxel] Trigger state:', trigger.name, 'enabled=' + trigger.enabled); + + // Update trigger state in UI if automation panel exists + if (window.Automations && window.Automations.updateTriggerState) { + window.Automations.updateTriggerState(trigger); + } + } + + function handleSystemHealthMessage(msg) { + if (!msg.health) return; + + const health = msg.health; + console.log('[Spaxel] System health:', health); + + // Update system health display in UI + const healthEl = document.getElementById('system-uptime'); + if (healthEl) { + const uptimeSec = health.uptime_s || 0; + const hours = Math.floor(uptimeSec / 3600); + const mins = Math.floor((uptimeSec % 3600) / 60); + healthEl.textContent = hours + 'h ' + mins + 'm'; + } + + const memEl = document.getElementById('system-memory'); + if (memEl) { + memEl.textContent = (health.mem_mb || 0).toFixed(1) + ' MB'; + } + + const goroutinesEl = document.getElementById('system-goroutines'); + if (goroutinesEl) { + goroutinesEl.textContent = health.go_routines || 0; + } + } + function handleMessage(data) { if (typeof data === 'string') { // JSON message @@ -580,8 +702,34 @@ fetchDiurnalStatus(); break; + case 'event': + // Event: presence transition, zone entry/exit, portal crossing + handleEventMessage(msg); + break; + + case 'alert': + // Alert: anomaly detection, security mode trigger + handleAlertMessage(msg); + break; + + case 'ble_scan': + // BLE device list update (5s interval) + handleBLEScanMessage(msg); + break; + + case 'trigger_state': + // Automation trigger state change + handleTriggerStateMessage(msg); + break; + + case 'system_health': + // System health stats (60s interval) + handleSystemHealthMessage(msg); + break; + default: - // Ignore unknown types (forward-compatible) + // Log unhandled types for future debugging + console.log('[Spaxel] Unknown message type:', msg.type, msg); } } diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index 89a1a98..d5cba76 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -23,6 +23,11 @@ type Hub struct { // Reference to ingestion server for state queries ingestionState IngestionState + + // Additional state providers + bleState BLEState + triggerState TriggerState + systemHealth SystemHealthProvider } // IngestionState is an interface to query node/link/motion state from ingestion @@ -32,6 +37,25 @@ type IngestionState interface { GetAllMotionStates() []ingestion.MotionStateItem } +// BLEState is an interface to query current BLE devices for dashboard broadcast +type BLEState interface { + GetCurrentDevices() []map[string]interface{} +} + +// TriggerState is an interface to query automation trigger states for dashboard broadcast +type TriggerState interface { + GetTriggerStates() []map[string]interface{} +} + +// SystemHealthProvider is an interface to query system health metrics +type SystemHealthProvider interface { + GetUptimeSeconds() int64 + GetNodeCount() int + GetBeadCount() int + GetGoRoutineCount() int + GetMemoryMB() float64 +} + // Client represents a dashboard WebSocket client type Client struct { hub *Hub @@ -55,6 +79,27 @@ func (h *Hub) SetIngestionState(state IngestionState) { h.mu.Unlock() } +// SetBLEState sets the BLE state provider +func (h *Hub) SetBLEState(state BLEState) { + h.mu.Lock() + h.bleState = state + h.mu.Unlock() +} + +// SetTriggerState sets the automation trigger state provider +func (h *Hub) SetTriggerState(state TriggerState) { + h.mu.Lock() + h.triggerState = state + h.mu.Unlock() +} + +// SetSystemHealth sets the system health provider +func (h *Hub) SetSystemHealth(provider SystemHealthProvider) { + h.mu.Lock() + h.systemHealth = provider + h.mu.Unlock() +} + // Run starts the hub's main loop func (h *Hub) Run() { stateTicker := time.NewTicker(5 * time.Second) @@ -63,6 +108,14 @@ func (h *Hub) Run() { presenceTicker := time.NewTicker(500 * time.Millisecond) defer presenceTicker.Stop() + // BLE scan broadcast ticker (5 seconds) + bleScanTicker := time.NewTicker(5 * time.Second) + defer bleScanTicker.Stop() + + // System health broadcast ticker (60 seconds) + healthTicker := time.NewTicker(60 * time.Second) + defer healthTicker.Stop() + for { select { case client := <-h.register: @@ -97,6 +150,12 @@ func (h *Hub) Run() { case <-presenceTicker.C: h.broadcastPresence() + + case <-bleScanTicker.C: + h.broadcastBLEScan() + + case <-healthTicker.C: + h.broadcastSystemHealth() } } } @@ -390,6 +449,45 @@ func (h *Hub) ClientCount() int { return len(h.clients) } +// broadcastBLEScan broadcasts the current BLE device list to all dashboard clients. +func (h *Hub) broadcastBLEScan() { + h.mu.RLock() + state := h.bleState + clientCount := len(h.clients) + h.mu.RUnlock() + + if state == nil || clientCount == 0 { + return + } + + devices := state.GetCurrentDevices() + if len(devices) == 0 { + return + } + + h.BroadcastBLEScan(devices) +} + +// broadcastSystemHealth broadcasts system health stats to all dashboard clients. +func (h *Hub) broadcastSystemHealth() { + h.mu.RLock() + provider := h.systemHealth + clientCount := len(h.clients) + h.mu.RUnlock() + + if provider == nil || clientCount == 0 { + return + } + + h.BroadcastSystemHealth( + provider.GetUptimeSeconds(), + provider.GetNodeCount(), + provider.GetBeadCount(), + provider.GetGoRoutineCount(), + provider.GetMemoryMB(), + ) +} + // BroadcastFleetChange broadcasts a fleet change event to all dashboard clients. // This implements the fleet.FleetChangeBroadcaster interface. func (h *Hub) BroadcastFleetChange(event fleet.FleetChangeEvent) { @@ -522,3 +620,77 @@ func (h *Hub) BroadcastAnomaly(anomaly interface{}) { data, _ := json.Marshal(msg) h.Broadcast(data) } + +// BroadcastEvent broadcasts an event (presence transition, zone entry/exit, portal crossing) to all dashboard clients. +func (h *Hub) BroadcastEvent(eventID string, timestamp time.Time, kind, zone string, blobID int, personName string) { + msg := map[string]interface{}{ + "type": "event", + "event": map[string]interface{}{ + "id": eventID, + "ts": timestamp.UnixMilli(), + "kind": kind, + "zone": zone, + "blob_id": blobID, + "person_name": personName, + }, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastAlert broadcasts an alert (anomaly detection, security mode trigger) to all dashboard clients. +func (h *Hub) BroadcastAlert(alertID string, timestamp time.Time, severity, description string, acknowledged bool) { + msg := map[string]interface{}{ + "type": "alert", + "alert": map[string]interface{}{ + "id": alertID, + "ts": timestamp.UnixMilli(), + "severity": severity, + "description": description, + "acknowledged": acknowledged, + }, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastBLEScan broadcasts BLE device list updates to all dashboard clients (5s interval). +func (h *Hub) BroadcastBLEScan(devices []map[string]interface{}) { + msg := map[string]interface{}{ + "type": "ble_scan", + "devices": devices, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastTriggerState broadcasts automation trigger state changes to all dashboard clients. +func (h *Hub) BroadcastTriggerState(triggerID, name string, lastFired time.Time, enabled bool) { + msg := map[string]interface{}{ + "type": "trigger_state", + "trigger": map[string]interface{}{ + "id": triggerID, + "name": name, + "last_fired": lastFired.UnixMilli(), + "enabled": enabled, + }, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +} + +// BroadcastSystemHealth broadcasts periodic system health stats to all dashboard clients (60s interval). +func (h *Hub) BroadcastSystemHealth(uptimeS int64, nodeCount, beadCount, goRoutines int, memMB float64) { + msg := map[string]interface{}{ + "type": "system_health", + "health": map[string]interface{}{ + "uptime_s": uptimeS, + "node_count": nodeCount, + "bead_count": beadCount, + "go_routines": goRoutines, + "mem_mb": memMB, + }, + } + data, _ := json.Marshal(msg) + h.Broadcast(data) +}