feat: expand dashboard WebSocket feed with events, alerts, BLE, triggers, and system health

- Add BroadcastEvent for presence transitions, zone entries/exits, portal crossings
- Add BroadcastAlert for anomaly detections and security mode triggers
- Add BroadcastBLEScan for BLE device list updates (5s interval)
- Add BroadcastTriggerState for automation trigger state changes
- Add BroadcastSystemHealth for periodic system stats (60s interval)
- Add BLEState, TriggerState, and SystemHealthProvider interfaces
- Update hub.Run with new tickers for BLE (5s) and system health (60s)
- Update dashboard app.js with handlers for new message types
- Add bleDevices map to state for BLE device tracking
- Log unhandled message types for future debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-06 10:17:12 -04:00
parent 37f013c58d
commit bfa3e6f2b2
2 changed files with 322 additions and 2 deletions

View file

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

View file

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