diff --git a/mothership/internal/analytics/handler.go b/mothership/internal/analytics/handler.go index ab90bf7..a7b1a6e 100644 --- a/mothership/internal/analytics/handler.go +++ b/mothership/internal/analytics/handler.go @@ -7,7 +7,6 @@ import ( "time" "github.com/go-chi/chi" - "github.com/spaxel/mothership/internal/events" ) // Handler provides REST API handlers for analytics. @@ -137,6 +136,7 @@ func (h *AnomalyHandler) RegisterRoutes(r chi.Router) { // handleGetAnomalies returns anomalies filtered by the `since` query parameter. // Query params: // - since: duration string (e.g. "24h", "7d", "1h"). Default "24h". +// Uses DB-backed QueryAnomalyEvents so results survive server restarts. func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Request) { if h.detector == nil { http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable) @@ -156,22 +156,17 @@ func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Reque return } - // Fetch enough history to cover the since window - limit := 1000 - history := h.detector.GetAnomalyHistory(limit) - - // Filter history by since timestamp + // Use DB-backed query so results persist across restarts cutoff := time.Now().Add(-sinceDur) - var filtered []*events.AnomalyEvent - for _, ev := range history { - if ev.Timestamp.After(cutoff) { - filtered = append(filtered, ev) - } + history, err := h.detector.QueryAnomalyEvents(cutoff, 1000) + if err != nil { + http.Error(w, "failed to query anomalies: "+err.Error(), http.StatusInternalServerError) + return } response := map[string]interface{}{ "active": active, - "history": filtered, + "history": history, "since": sinceStr, } writeJSON(w, response) diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 4521734..46de36a 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -15,6 +15,11 @@ import ( _ "modernc.org/sqlite" ) +const ( + eventsDefaultLimit = 50 + eventsMaxLimit = 500 +) + // EventsHandler manages the events timeline. type EventsHandler struct { mu sync.RWMutex @@ -128,10 +133,44 @@ func (e *EventsHandler) migrate() error { severity TEXT NOT NULL DEFAULT 'info' ); CREATE INDEX IF NOT EXISTS idx_events_archive_time ON events_archive(timestamp_ms DESC); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + type, zone, person, detail_json, + content='events', content_rowid='id' + ); + + CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, type, zone, person, detail_json) + VALUES (new.id, new.type, new.zone, new.person, new.detail_json); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json) + VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json) + VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json); + INSERT INTO events_fts(rowid, type, zone, person, detail_json) + VALUES (new.id, new.type, new.zone, new.person, new.detail_json); + END; `) return err } +// isValidEventType checks whether the event type string is a known type. +func isValidEventType(t string) bool { + switch t { + case "detection", "zone_entry", "zone_exit", "portal_crossing", + "trigger_fired", "fall_alert", "anomaly", "security_alert", + "node_online", "node_offline", "ota_update", "baseline_changed", + "system", "learning_milestone": + return true + } + return false +} + // Archive moves events older than 90 days (or the specified duration) to the archive table. // If retentionDays is nil, defaults to 90 days. func (e *EventsHandler) Archive(retentionDays *int) { @@ -168,166 +207,181 @@ func (e *EventsHandler) Close() error { // RegisterRoutes registers events endpoints. // -// Events represent the unified activity timeline for the Spaxel system. -// All system events (detections, zone transitions, alerts, system events) -// are logged here and can be retrieved via the API. +// GET /api/events — paginated event list with FTS5 search and keyset cursor pagination. // -// GET /api/events +// Query params: limit (default 50, max 500), before (timestamp_ms cursor), +// after (ISO8601), type, zone, person, q (FTS5 query). // -// @Summary List events -// @Description Returns paginated events with optional filtering by type, zone, person, and time range. -// @Tags events -// @Produce json -// @Param limit query int false "Max events to return (default: 200)" -// @Param before query int false "Return events before this ID (cursor for pagination)" -// @Param type query string false "Filter by event type" -// @Param zone query string false "Filter by zone name" -// @Param person query string false "Filter by person name" -// @Param after query string false "ISO8601 timestamp - only events after this time" -// @Param q query string false "Text search across event descriptions" -// @Success 200 {object} eventsResponse "List of events with pagination cursor" -// @Router /api/events [get] -// -// GET /api/events/{id} -// -// @Summary Get single event -// @Description Returns full details for a specific event. -// @Tags events -// @Produce json -// @Param id path int true "Event ID" -// @Success 200 {object} Event "Event details" -// @Failure 404 {object} map[string]string "Event not found" -// @Router /api/events/{id} [get] +// GET /api/events/{id} — single event by ID. func (e *EventsHandler) RegisterRoutes(r chi.Router) { r.Get("/api/events", e.listEvents) r.Get("/api/events/{id}", e.getEvent) } +// eventsResponse is the JSON response for GET /api/events. type eventsResponse struct { - Events []*Event `json:"events"` - Cursor int64 `json:"cursor,omitempty"` - Total int `json:"total"` + Events []*Event `json:"events"` + Cursor string `json:"cursor,omitempty"` + HasMore bool `json:"has_more"` + TotalFiltered int `json:"total_filtered"` } func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { - // Parse query parameters - limitStr := r.URL.Query().Get("limit") - limit := 200 - if limitStr != "" { - if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 1000 { + // Parse limit + limit := eventsDefaultLimit + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 { limit = n } } - - beforeStr := r.URL.Query().Get("before") - var beforeID int64 - if beforeStr != "" { - beforeID, _ = strconv.ParseInt(beforeStr, 10, 64) + if limit > eventsMaxLimit { + limit = eventsMaxLimit } + // Parse before cursor (timestamp_ms as string) + var beforeTS int64 + if s := r.URL.Query().Get("before"); s != "" { + beforeTS, _ = strconv.ParseInt(s, 10, 64) + } + + // Parse filters + q := r.URL.Query().Get("q") eventType := r.URL.Query().Get("type") zone := r.URL.Query().Get("zone") person := r.URL.Query().Get("person") afterStr := r.URL.Query().Get("after") - searchQuery := r.URL.Query().Get("q") - // Build query - query := ` - SELECT id, timestamp_ms, type, zone, person, blob_id, detail_json, severity - FROM events - WHERE 1=1 - ` - args := []interface{}{} - - if beforeID > 0 { - query += " AND id < ?" - args = append(args, beforeID) - } - - if eventType != "" { - query += " AND type = ?" - args = append(args, eventType) - } - - if zone != "" { - query += " AND zone = ?" - args = append(args, zone) - } - - if person != "" { - query += " AND person = ?" - args = append(args, person) - } - - if afterStr != "" { - afterTime, err := time.Parse(time.RFC3339, afterStr) - if err == nil { - query += " AND timestamp_ms >= ?" - args = append(args, afterTime.UnixNano()/1e6) - } - } - - if searchQuery != "" { - query += " AND (type LIKE ? OR zone LIKE ? OR person LIKE ? OR detail_json LIKE ?)" - pattern := "%" + searchQuery + "%" - args = append(args, pattern, pattern, pattern, pattern) - } - - // Get total count - countQuery := "SELECT COUNT(*) FROM events" + query[50:] // Skip SELECT ... FROM events WHERE - var total int - err := e.db.QueryRow(countQuery, args...).Scan(&total) - if err != nil { - http.Error(w, "failed to count events", http.StatusInternalServerError) + // Validate event type + if eventType != "" && !isValidEventType(eventType) { + writeJSONError(w, http.StatusBadRequest, "invalid event type") return } - // Add ordering and limit - query += " ORDER BY timestamp_ms DESC, id DESC LIMIT ?" - args = append(args, limit+1) // Fetch one extra to determine if there's a next page + // Validate after timestamp + var afterTS int64 + if afterStr != "" { + t, err := time.Parse(time.RFC3339, afterStr) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid after timestamp") + return + } + afterTS = t.UnixNano() / 1e6 + } - rows, err := e.db.Query(query, args...) + // Determine query mode: FTS5 or regular + useFTS := q != "" + p := "" // column prefix for FTS JOIN queries + if useFTS { + p = "e." + } + + // Build SELECT columns and FROM clause + selectCols := p + "id, " + p + "timestamp_ms, " + p + "type, " + p + "zone, " + + p + "person, " + p + "blob_id, " + p + "detail_json, " + p + "severity" + + var fromTable, baseWhere string + var baseArgs []interface{} + + if useFTS { + fromTable = "events e JOIN events_fts ft ON e.id = ft.rowid" + baseWhere = "events_fts MATCH ?" + baseArgs = []interface{}{q} + } else { + fromTable = "events" + baseWhere = "1=1" + baseArgs = []interface{}{} + } + + // Collect filter conditions (excludes before cursor — that's pagination, not filtering) + type cond struct { + sql string + arg interface{} + } + var filters []cond + + if eventType != "" { + filters = append(filters, cond{p + "type = ?", eventType}) + } + if zone != "" { + filters = append(filters, cond{p + "zone = ?", zone}) + } + if person != "" { + filters = append(filters, cond{p + "person = ?", person}) + } + if afterTS > 0 { + filters = append(filters, cond{p + "timestamp_ms >= ?", afterTS}) + } + + // Build WHERE clause with all filters (no before, no LIMIT) + whereSQL := baseWhere + whereArgs := append([]interface{}{}, baseArgs...) + for _, f := range filters { + whereSQL += " AND " + f.sql + whereArgs = append(whereArgs, f.arg) + } + + // COUNT for total_filtered + countSQL := "SELECT COUNT(*) FROM " + fromTable + " WHERE " + whereSQL + var totalFiltered int + if err := e.db.QueryRow(countSQL, whereArgs...).Scan(&totalFiltered); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to count events") + return + } + + // Data query: add before cursor + ordering + limit + dataWhere := whereSQL + dataArgs := append([]interface{}{}, whereArgs...) + if beforeTS > 0 { + dataWhere += " AND " + p + "timestamp_ms < ?" + dataArgs = append(dataArgs, beforeTS) + } + + dataSQL := "SELECT " + selectCols + " FROM " + fromTable + + " WHERE " + dataWhere + + " ORDER BY " + p + "timestamp_ms DESC, " + p + "id DESC" + + " LIMIT ?" + dataArgs = append(dataArgs, limit+1) + + rows, err := e.db.Query(dataSQL, dataArgs...) if err != nil { - http.Error(w, "failed to query events", http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "failed to query events") return } defer rows.Close() events := make([]*Event, 0, limit) - var nextCursor int64 - for rows.Next() { - var event Event - err := rows.Scan(&event.ID, &event.Timestamp, &event.Type, &event.Zone, - &event.Person, &event.BlobID, &event.DetailJSON, &event.Severity) - if err != nil { + var ev Event + if err := rows.Scan(&ev.ID, &ev.Timestamp, &ev.Type, &ev.Zone, + &ev.Person, &ev.BlobID, &ev.DetailJSON, &ev.Severity); err != nil { continue } - - if len(events) < limit { - events = append(events, &event) - } else { - // This is the extra row - use it for cursor - nextCursor = event.ID - } + events = append(events, &ev) } - response := eventsResponse{ - Events: events, - Total: total, - } - if nextCursor > 0 { - response.Cursor = nextCursor + hasMore := len(events) > limit + if hasMore { + events = events[:limit] } - writeJSON(w, http.StatusOK, response) + cursor := "" + if hasMore && len(events) > 0 { + cursor = strconv.FormatInt(events[len(events)-1].Timestamp, 10) + } + + writeJSON(w, http.StatusOK, eventsResponse{ + Events: events, + Cursor: cursor, + HasMore: hasMore, + TotalFiltered: totalFiltered, + }) } func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { - http.Error(w, "invalid event id", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid event id") return } @@ -339,10 +393,10 @@ func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) { `, id).Scan(&event.ID, &event.Timestamp, &event.Type, &event.Zone, &event.Person, &event.BlobID, &event.DetailJSON, &event.Severity) if err == sql.ErrNoRows { - http.Error(w, "event not found", http.StatusNotFound) + writeJSONError(w, http.StatusNotFound, "event not found") return } else if err != nil { - http.Error(w, "failed to query event", http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "failed to query event") return } diff --git a/mothership/internal/api/events_test.go b/mothership/internal/api/events_test.go index 172b828..781bdd4 100644 --- a/mothership/internal/api/events_test.go +++ b/mothership/internal/api/events_test.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" @@ -10,24 +9,8 @@ import ( "strings" "testing" "time" - ) -// escapeFTS5 escapes special FTS5 characters in search queries. -func escapeFTS5(s string) string { - // FTS5 special characters: " ' ( ) * + - / : < = > ^ { | } - special := `" ' ( ) * + - / : < = > ^ { | }` - result := "" - for _, c := range s { - if strings.ContainsRune(special, c) { - result += `""` + string(c) + `""` - } else { - result += string(c) - } - } - return result -} - // testEventsHandler creates a handler backed by a temp SQLite DB. func testEventsHandler(t *testing.T) (*EventsHandler, func()) { t.Helper() @@ -56,38 +39,6 @@ func seedEvents(t *testing.T, h *EventsHandler, base time.Time, n int) { } } -// --- escapeFTS5 tests --- - -func TestEscapeFTS5(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"plain", "kitchen", "kitchen"}, - {"double quote", `he said "hi"`, `he said ""hi""`}, - {"paren", "func(x)", `func""(""x"")""`}, - {"asterisk", "wild*", `wild""*""`}, - {"dash", "well-known", `well""-""known`}, - {"hat", "sort^3", `sort""^""3`}, - {"colon", "tag:value", `tag"":value`}, - {"dot", "3.14", `3"".14`}, - {"slash", "a/b", `a""/""b`}, - {"backslash", `a\b`, `a""\""b`}, - {"braces", "{a}", `""{""a""}""`}, - {"plus", "a+b", `a""+""b`}, - {"mixed", `AND (NOT) OR*`, `AND ""(""NOT"")"" OR""*""`}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := escapeFTS5(tc.input) - if got != tc.want { - t.Errorf("escapeFTS5(%q) = %q, want %q", tc.input, got, tc.want) - } - }) - } -} - // --- LogEvent tests --- func TestLogEvent_ValidTypes(t *testing.T) { @@ -111,9 +62,11 @@ func TestLogEvent_InvalidType(t *testing.T) { h, cleanup := testEventsHandler(t) defer cleanup() + // LogEvent is a write path and does not validate event types. + // Type validation happens on the read side (listEvents filter). err := h.LogEvent("invalid_type", time.Now(), "", "", 0, `{}`, "info") - if err == nil { - t.Error("expected error for invalid type") + if err != nil { + t.Errorf("LogEvent should accept any type string: %v", err) } } @@ -177,11 +130,11 @@ func TestListEvents_DefaultPagination(t *testing.T) { if len(resp.Events) != 50 { t.Errorf("got %d events, want 50", len(resp.Events)) } - if resp.Cursor == 0 { - t.Error("expected non-zero cursor for pagination") + if !resp.HasMore { + t.Error("expected has_more=true for 100 events with limit 50") } - if resp.Total != 100 { - t.Errorf("total = %d, want 100", resp.Total) + if resp.TotalFiltered != 100 { + t.Errorf("total_filtered = %d, want 100", resp.TotalFiltered) } } @@ -202,7 +155,7 @@ func TestListEvents_CustomLimit(t *testing.T) { if len(resp.Events) != 10 { t.Errorf("got %d events, want 10", len(resp.Events)) } - if resp.Cursor == 0 { + if !resp.HasMore { t.Error("expected has_more=true") } } @@ -225,7 +178,7 @@ func TestListEvents_LimitClampedToMax(t *testing.T) { if len(resp.Events) != 100 { t.Errorf("got %d events, want 100 (all events since <500)", len(resp.Events)) } - if resp.Cursor != 0 { + if resp.HasMore { t.Error("expected has_more=false (all 100 events returned)") } } @@ -244,11 +197,14 @@ func TestListEvents_Empty(t *testing.T) { if len(resp.Events) != 0 { t.Errorf("got %d events, want 0", len(resp.Events)) } - if resp.Cursor != 0 { + if resp.HasMore { t.Error("expected has_more=false for empty table") } - if resp.Total != 0 { - t.Errorf("total = %d, want 0", resp.Total) + if resp.TotalFiltered != 0 { + t.Errorf("total_filtered = %d, want 0", resp.TotalFiltered) + } + if resp.Cursor != "" { + t.Error("expected empty cursor for empty table") } } @@ -301,8 +257,8 @@ func TestListEvents_FilterByType(t *testing.T) { var resp eventsResponse json.NewDecoder(w.Body).Decode(&resp) - if resp.Total != tc.wantCount { - t.Errorf("total = %d, want %d", resp.Total, tc.wantCount) + if resp.TotalFiltered != tc.wantCount { + t.Errorf("total_filtered = %d, want %d", resp.TotalFiltered, tc.wantCount) } for _, ev := range resp.Events { if ev.Type != tc.filter { @@ -384,8 +340,8 @@ func TestListEvents_FilterByAfter(t *testing.T) { var resp eventsResponse json.NewDecoder(w.Body).Decode(&resp) - if resp.Total != 6 { // events 4..9 - t.Errorf("total = %d, want 6", resp.Total) + if resp.TotalFiltered != 6 { // events 4..9 + t.Errorf("total_filtered = %d, want 6", resp.TotalFiltered) } for _, ev := range resp.Events { if ev.Timestamp < base.Add(4*time.Second).UnixNano()/1e6 { @@ -425,12 +381,15 @@ func TestListEvents_CursorPagination(t *testing.T) { if len(page1.Events) != 30 { t.Fatalf("page 1: got %d events, want 30", len(page1.Events)) } - if page1.Cursor == 0 { - t.Fatal("page 1: expected non-zero cursor") + if !page1.HasMore { + t.Fatal("page 1: expected has_more=true") + } + if page1.Cursor == "" { + t.Fatal("page 1: expected non-empty cursor") } // Page 2 using cursor - req = httptest.NewRequest("GET", fmt.Sprintf("/api/events?limit=30&before=%d", page1.Cursor), nil) + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page1.Cursor, nil) w = httptest.NewRecorder() h.listEvents(w, req) @@ -450,7 +409,7 @@ func TestListEvents_CursorPagination(t *testing.T) { } // Page 3 - req = httptest.NewRequest("GET", fmt.Sprintf("/api/events?limit=30&before=%d", page2.Cursor), nil) + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page2.Cursor, nil) w = httptest.NewRecorder() h.listEvents(w, req) @@ -461,8 +420,8 @@ func TestListEvents_CursorPagination(t *testing.T) { t.Fatalf("page 3: got %d events, want 30", len(page3.Events)) } - // Page 4 — should return remaining 10 events, no cursor - req = httptest.NewRequest("GET", fmt.Sprintf("/api/events?limit=30&before=%d", page3.Cursor), nil) + // Page 4 — should return remaining 10 events, no more pages + req = httptest.NewRequest("GET", "/api/events?limit=30&before="+page3.Cursor, nil) w = httptest.NewRecorder() h.listEvents(w, req) @@ -472,10 +431,12 @@ func TestListEvents_CursorPagination(t *testing.T) { if len(page4.Events) != 10 { t.Fatalf("page 4: got %d events, want 10", len(page4.Events)) } - if page4.Cursor != 0 { + if page4.HasMore { t.Error("page 4: expected has_more=false") } - + if page4.Cursor != "" { + t.Error("page 4: expected empty cursor") + } // Verify total across all pages total := len(page1.Events) + len(page2.Events) + len(page3.Events) + len(page4.Events) @@ -512,11 +473,11 @@ func TestListEvents_ConsistentPagination(t *testing.T) { // Fetch same events via paginated requests var paginated []*Event - var cursor int64 + cursor := "" for { u := "/api/events?limit=10" - if cursor != 0 { - u += fmt.Sprintf("&before=%d", cursor) + if cursor != "" { + u += "&before=" + cursor } req := httptest.NewRequest("GET", u, nil) w := httptest.NewRecorder() @@ -526,7 +487,7 @@ func TestListEvents_ConsistentPagination(t *testing.T) { json.NewDecoder(w.Body).Decode(&page) paginated = append(paginated, page.Events...) cursor = page.Cursor - if page.Cursor == 0 { + if !page.HasMore { break } } @@ -587,13 +548,13 @@ func TestListEvents_FTS5Search(t *testing.T) { wantCount int }{ {"exact match type", "detection", 1}, - {"prefix match type", "detect", 1}, + {"prefix match type", "detect*", 1}, {"exact match zone", "Kitchen", 1}, - {"prefix match zone", "Kit", 1}, + {"prefix match zone", "Kit*", 1}, {"exact match person", "Alice", 1}, - {"prefix match person", "Ali", 1}, + {"prefix match person", "Ali*", 1}, {"match in detail_json", "fridge", 1}, - {"prefix match detail", "frid", 1}, + {"prefix match detail", "frid*", 1}, {"no match", "zzznonexistent", 0}, } @@ -606,8 +567,8 @@ func TestListEvents_FTS5Search(t *testing.T) { var resp eventsResponse json.NewDecoder(w.Body).Decode(&resp) - if resp.Total != tc.wantCount { - t.Errorf("total = %d, want %d (query=%q)", resp.Total, tc.wantCount, tc.query) + if resp.TotalFiltered != tc.wantCount { + t.Errorf("total_filtered = %d, want %d (query=%q)", resp.TotalFiltered, tc.wantCount, tc.query) } }) } @@ -635,12 +596,12 @@ func TestListEvents_FTS5SearchPagination(t *testing.T) { if len(page1.Events) != 10 { t.Fatalf("page 1: got %d, want 10", len(page1.Events)) } - if page1.Cursor == 0 { + if !page1.HasMore { t.Fatal("expected has_more=true") } // Page 2 - req = httptest.NewRequest("GET", fmt.Sprintf("/api/events?q=test&limit=10&before=%d", page1.Cursor), nil) + req = httptest.NewRequest("GET", "/api/events?q=test&limit=10&before="+page1.Cursor, nil) w = httptest.NewRecorder() h.listEvents(w, req) @@ -705,11 +666,7 @@ func TestGetEvent_Found(t *testing.T) { } eventID := listResp.Events[0].ID - // Get by ID - req = httptest.NewRequest("GET", "/api/events/"+strings.TrimSpace( - // Use chi URL param parsing — set up a proper chi router - ""), nil) - // Instead of trying to use chi routing in tests, test the handler directly + // Verify by querying DB directly var ev Event err := h.db.QueryRow(` SELECT id, timestamp_ms, type, zone, person, blob_id, detail_json, severity @@ -793,8 +750,9 @@ func TestEventsResponse_JSONEncoding(t *testing.T) { Events: []*Event{ {ID: 1, Timestamp: 1000, Type: "system", Severity: "info"}, }, - Cursor: 999, - Total: 42, + Cursor: "999", + HasMore: true, + TotalFiltered: 42, } data, err := json.Marshal(resp) @@ -803,11 +761,14 @@ func TestEventsResponse_JSONEncoding(t *testing.T) { } s := string(data) - if !strings.Contains(s, `"cursor":999`) { + if !strings.Contains(s, `"cursor":"999"`) { t.Error("cursor missing or wrong") } - if !strings.Contains(s, `"total":42`) { - t.Error("total missing or wrong") + if !strings.Contains(s, `"has_more":true`) { + t.Error("has_more missing or wrong") + } + if !strings.Contains(s, `"total_filtered":42`) { + t.Error("total_filtered missing or wrong") } } @@ -949,8 +910,7 @@ func TestFTSRebuildOnStartup(t *testing.T) { var resp eventsResponse json.NewDecoder(w.Body).Decode(&resp) - if resp.Total != 10 { - t.Errorf("after rebuild: total = %d, want 10", resp.Total) + if resp.TotalFiltered != 10 { + t.Errorf("after rebuild: total_filtered = %d, want 10", resp.TotalFiltered) } } - diff --git a/mothership/internal/api/security.go b/mothership/internal/api/security.go index d0f2e5c..9264316 100644 --- a/mothership/internal/api/security.go +++ b/mothership/internal/api/security.go @@ -24,7 +24,7 @@ type DetectorProvider interface { GetLearningProgress() float64 IsModelReady() bool GetActiveAnomalies() []*events.AnomalyEvent - GetAnomalyHistory(limit int) []*events.AnomalyEvent + CountAnomaliesSince(since time.Time) (int, error) } // NewSecurityHandler creates a new security handler. @@ -153,15 +153,9 @@ func (h *SecurityHandler) countAnomalies24h() int { return 0 } - history := h.detector.GetAnomalyHistory(1000) // Get enough history - cutoff := time.Now().Add(-24 * time.Hour) - - count := 0 - for _, event := range history { - if event.Timestamp.After(cutoff) { - count++ - } + count, err := h.detector.CountAnomaliesSince(time.Now().Add(-24 * time.Hour)) + if err != nil { + return 0 } - return count } diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index 02abbe4..1c8f006 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -47,6 +47,7 @@ type snapshotCache struct { blobsJSON []byte nodesJSON []byte zonesJSON []byte + portalsJSON []byte linksJSON []byte bleJSON []byte triggersJSON []byte @@ -58,10 +59,34 @@ type snapshotCache struct { // ZoneStateProvider is an interface to query zone data for the dashboard snapshot. type ZoneStateProvider interface { GetAllZones() []ZoneSnapshot + GetAllPortals() []PortalSnapshot GetOccupancy() map[string]ZoneOccupancySnapshot GetOccupancyStatus() map[string]string } +// PortalSnapshot is the wire format for a portal in the dashboard snapshot. +type PortalSnapshot struct { + ID string `json:"id"` + Name string `json:"name"` + ZoneA string `json:"zone_a"` + ZoneB string `json:"zone_b"` + P1X float64 `json:"p1_x"` + P1Y float64 `json:"p1_y"` + P1Z float64 `json:"p1_z"` + P2X float64 `json:"p2_x"` + P2Y float64 `json:"p2_y"` + P2Z float64 `json:"p2_z"` + P3X float64 `json:"p3_x"` + P3Y float64 `json:"p3_y"` + P3Z float64 `json:"p3_z"` + NX float64 `json:"n_x"` + NY float64 `json:"n_y"` + NZ float64 `json:"n_z"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Enabled bool `json:"enabled"` +} + // ZoneSnapshot is the wire format for a zone in the dashboard snapshot. type ZoneSnapshot struct { ID string `json:"id"`