diff --git a/dashboard/js/app.js b/dashboard/js/app.js index c24f83c..9dcb65d 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -522,15 +522,19 @@ 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 days = Math.floor(uptimeSec / 86400); + const hours = Math.floor((uptimeSec % 86400) / 3600); const mins = Math.floor((uptimeSec % 3600) / 60); - healthEl.textContent = hours + 'h ' + mins + 'm'; + if (days > 0) { + healthEl.textContent = days + 'd ' + hours + 'h ' + mins + 'm'; + } else { + healthEl.textContent = hours + 'h ' + mins + 'm'; + } } const memEl = document.getElementById('system-memory'); diff --git a/mothership/internal/api/volume_triggers.go b/mothership/internal/api/volume_triggers.go index 623ea08..c64999c 100644 --- a/mothership/internal/api/volume_triggers.go +++ b/mothership/internal/api/volume_triggers.go @@ -33,6 +33,7 @@ type NotificationClient interface { // WSBroadcaster sends messages to dashboard WebSocket clients. type WSBroadcaster interface { BroadcastAlert(alertID string, timestamp time.Time, severity, description string, acknowledged bool) + BroadcastTriggerState(triggerID, name string, lastFired time.Time, enabled bool) } // VolumeTriggersHandler manages automation trigger volumes with 3D geometry. @@ -418,6 +419,9 @@ func (h *VolumeTriggersHandler) enableTrigger(w http.ResponseWriter, r *http.Req return } + // Broadcast updated trigger state to dashboard + h.broadcastTriggerState(id) + writeJSON(w, map[string]interface{}{"status": "ok"}) } @@ -437,6 +441,9 @@ func (h *VolumeTriggersHandler) disableTrigger(w http.ResponseWriter, r *http.Re return } + // Broadcast updated trigger state to dashboard + h.broadcastTriggerState(id) + writeJSON(w, map[string]interface{}{"status": "ok"}) } @@ -547,6 +554,13 @@ func (h *VolumeTriggersHandler) onTriggerFired(event volume.FiredEvent) { } // Broadcast trigger state to dashboard + h.mu.RLock() + broadcaster := h.wsBroadcaster + h.mu.RUnlock() + if broadcaster != nil { + broadcaster.BroadcastTriggerState(t.ID, t.Name, event.Timestamp, t.Enabled) + } + log.Printf("[INFO] Trigger fired: %s (%s, %d blob(s))", t.Name, t.Condition, len(event.BlobIDs)) } @@ -739,3 +753,42 @@ func (h *VolumeTriggersHandler) executeNotification(action volume.Action, event log.Printf("[WARN] Notification failed: %v", err) } } + +// broadcastTriggerState sends a trigger_state WebSocket message for a trigger by ID. +func (h *VolumeTriggersHandler) broadcastTriggerState(triggerID string) { + t := h.store.GetTrigger(triggerID) + if t == nil { + return + } + + h.mu.RLock() + broadcaster := h.wsBroadcaster + h.mu.RUnlock() + + if broadcaster != nil { + var lastFired time.Time + if t.LastFired != nil { + lastFired = *t.LastFired + } + broadcaster.BroadcastTriggerState(t.ID, t.Name, lastFired, t.Enabled) + } +} + +// GetTriggerStates returns all trigger states for the dashboard snapshot/delta protocol. +// Implements dashboard.TriggerState interface. +func (h *VolumeTriggersHandler) GetTriggerStates() []map[string]interface{} { + triggers := h.store.GetAll() + states := make([]map[string]interface{}, 0, len(triggers)) + for _, t := range triggers { + state := map[string]interface{}{ + "id": t.ID, + "name": t.Name, + "enabled": t.Enabled, + } + if t.LastFired != nil { + state["last_fired"] = t.LastFired.UnixMilli() + } + states = append(states, state) + } + return states +} diff --git a/mothership/internal/dashboard/hub_test.go b/mothership/internal/dashboard/hub_test.go index 44eeb97..0473ec0 100644 --- a/mothership/internal/dashboard/hub_test.go +++ b/mothership/internal/dashboard/hub_test.go @@ -777,3 +777,95 @@ func TestHub_DeltaOmitsTypeField(t *testing.T) { t.Error("expected at least one delta message (no type field)") } } + +func TestHub_BroadcastTriggerState(t *testing.T) { + tests := []struct { + name string + triggerID string + triggerName string + lastFired time.Time + enabled bool + }{ + { + name: "enabled trigger with last fired", + triggerID: "trigger-1", + triggerName: "Couch Dwell", + lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC), + enabled: true, + }, + { + name: "disabled trigger never fired", + triggerID: "trigger-2", + triggerName: "Hallway Motion", + lastFired: time.Time{}, + enabled: false, + }, + { + name: "enabled trigger never fired", + triggerID: "trigger-3", + triggerName: "Kitchen Entry", + lastFired: time.Time{}, + enabled: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hub := NewHub() + go hub.Run() + + client := &Client{ + hub: hub, + send: make(chan []byte, 10), + } + + hub.Register(client) + time.Sleep(10 * time.Millisecond) + drainSnapshot(t, client.send) + + hub.BroadcastTriggerState(tc.triggerID, tc.triggerName, tc.lastFired, tc.enabled) + + select { + case msg := <-client.send: + var parsed map[string]interface{} + if err := json.Unmarshal(msg, &parsed); err != nil { + t.Fatalf("failed to parse trigger_state JSON: %v", err) + } + + if parsed["type"] != "trigger_state" { + t.Errorf("expected type=trigger_state, got %v", parsed["type"]) + } + + trigger, ok := parsed["trigger"].(map[string]interface{}) + if !ok { + t.Fatal("missing trigger object") + } + + if trigger["id"] != tc.triggerID { + t.Errorf("expected id=%s, got %v", tc.triggerID, trigger["id"]) + } + if trigger["name"] != tc.triggerName { + t.Errorf("expected name=%s, got %v", tc.triggerName, trigger["name"]) + } + if trigger["enabled"] != tc.enabled { + t.Errorf("expected enabled=%v, got %v", tc.enabled, trigger["enabled"]) + } + + // Verify last_fired + if !tc.lastFired.IsZero() { + tsVal, ok := trigger["last_fired"].(float64) + if !ok { + t.Fatalf("expected last_fired to be numeric, got %T", trigger["last_fired"]) + } + expectedTs := float64(tc.lastFired.UnixMilli()) + if tsVal != expectedTs { + t.Errorf("expected last_fired=%v, got %v", expectedTs, tsVal) + } + } + + case <-time.After(100 * time.Millisecond): + t.Error("expected to receive trigger_state broadcast") + } + }) + } +}