diff --git a/dashboard/js/timeline.js b/dashboard/js/timeline.js index 3fcfc7c..00ad68b 100644 --- a/dashboard/js/timeline.js +++ b/dashboard/js/timeline.js @@ -489,7 +489,7 @@ if (item.el) { item.el.addEventListener('change', function() { state.filters.categories[item.key] = item.el.checked; - applyClientSideFilters(); + loadInitialEvents(); }); } }); @@ -513,6 +513,15 @@ clearTimeout(searchTimeout); searchTimeout = setTimeout(onSearchChange, CONFIG.debounceMs); }); + // Enter key triggers server-side FTS5 search + elements.filterSearch.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + clearTimeout(searchTimeout); + state.filters.q = elements.filterSearch.value.trim() || null; + loadInitialEvents(); + } + }); } // Custom date range @@ -730,6 +739,46 @@ } } + // Fuzzy match: checks if all characters in query appear in text in order. + // "ktchn" matches "kitchen", "alce" matches "alice", etc. + // Returns a score (lower = better match) or -1 if no match. + function fuzzyMatch(query, text) { + if (!query || !text) return -1; + query = query.toLowerCase(); + text = text.toLowerCase(); + + // Fast path: exact substring match + var idx = text.indexOf(query); + if (idx !== -1) return idx; + + // Character-sequence matching + var qi = 0; + var score = 0; + var lastMatchIdx = -1; + + for (var ti = 0; ti < text.length && qi < query.length; ti++) { + if (text[ti] === query[qi]) { + score += (lastMatchIdx === ti - 1) ? 0 : 1; + lastMatchIdx = ti; + qi++; + } + } + + return qi === query.length ? score : -1; + } + + // Multi-word fuzzy match: splits query into tokens, all must match. + // "alice kit" matches "Alice entered Kitchen" (both tokens match). + function fuzzyMatchAll(query, text) { + if (!query || !text) return false; + var tokens = query.toLowerCase().split(/\s+/).filter(function(t) { return t.length > 0; }); + if (tokens.length === 0) return true; + var textLower = text.toLowerCase(); + return tokens.every(function(token) { + return fuzzyMatch(token, textLower) >= 0; + }); + } + function disableCategoryCheckboxes(disabled) { const checkboxes = [ elements.categoryPresence, @@ -799,31 +848,21 @@ // Apply text search with fuzzy matching if (state.filters.q) { - const searchLower = state.filters.q.toLowerCase(); + const query = state.filters.q; filtered = filtered.filter(function(event) { - // Search in type, zone, person, and detail_json - if (event.type && event.type.toLowerCase().indexOf(searchLower) !== -1) return true; - if (event.zone && event.zone.toLowerCase().indexOf(searchLower) !== -1) return true; - if (event.person && event.person.toLowerCase().indexOf(searchLower) !== -1) return true; - - // Parse detail_json for additional search + var searchParts = [event.type || '', event.zone || '', event.person || '']; if (event.detail_json) { try { - const detail = JSON.parse(event.detail_json); - const detailStr = JSON.stringify(detail).toLowerCase(); - if (detailStr.indexOf(searchLower) !== -1) return true; - - // Check description field specifically - if (detail.description && detail.description.toLowerCase().indexOf(searchLower) !== -1) { - return true; - } + var detail = JSON.parse(event.detail_json); + if (detail.description) searchParts.push(detail.description); + if (detail.message) searchParts.push(detail.message); + searchParts.push(JSON.stringify(detail)); } catch (e) { - // If not JSON, search as string - if (event.detail_json.toLowerCase().indexOf(searchLower) !== -1) return true; + searchParts.push(event.detail_json); } } - - return false; + var combinedText = searchParts.join(' '); + return fuzzyMatchAll(query, combinedText); }); } @@ -840,6 +879,13 @@ // Server-side filters if (state.filters.type) { params.set('type', state.filters.type); + } else { + // Send category-based types filter to server for efficient initial loading + var enabledTypes = getEnabledCategoryTypes(); + var allTypes = getAllCategoryTypes(); + if (enabledTypes.length > 0 && enabledTypes.length < allTypes.length) { + params.set('types', enabledTypes.join(',')); + } } if (state.filters.zone) { params.set('zone', state.filters.zone); @@ -860,6 +906,31 @@ params.set('mode', state.dashboardMode); } + function getEnabledCategoryTypes() { + var types = []; + Object.keys(state.filters.categories).forEach(function(cat) { + if (state.filters.categories[cat]) { + var catTypes = EVENT_CATEGORIES[cat]; + if (catTypes) { + catTypes.forEach(function(t) { + if (types.indexOf(t) === -1) types.push(t); + }); + } + } + }); + return types; + } + + function getAllCategoryTypes() { + var types = []; + Object.keys(EVENT_CATEGORIES).forEach(function(cat) { + EVENT_CATEGORIES[cat].forEach(function(t) { + if (types.indexOf(t) === -1) types.push(t); + }); + }); + return types; + } + // ============================================ // WebSocket Message Handler // ============================================ @@ -905,8 +976,7 @@ // Prepend to DOM if timeline is visible and event passes filters if (elements.container && elements.container.style.display !== 'none' && elements.eventsList) { // Check if event passes current filters - const passesFilters = state.filteredEvents.length > 0 && - state.filteredEvents[0].id === normalizedEvent.id; + const passesFilters = state.filteredEvents.some(function(e) { return e.id === normalizedEvent.id; }); if (!passesFilters) return; // Don't show if filtered out diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 976c5b4..cb223f6 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -55,6 +55,7 @@ import ( "github.com/spaxel/mothership/internal/shutdown" sigproc "github.com/spaxel/mothership/internal/signal" "github.com/spaxel/mothership/internal/sleep" + "github.com/spaxel/mothership/internal/timeline" "github.com/spaxel/mothership/internal/startup" "github.com/spaxel/mothership/internal/volume" "github.com/spaxel/mothership/internal/webhook" @@ -459,6 +460,11 @@ func main() { eventsHandler := api.NewEventsHandlerFromDB(mainDB) log.Printf("[INFO] Events handler initialized (shared DB)") + // Timeline storage subscriber: reads from EventBus and writes to SQLite asynchronously + // using a 1000-event buffered queue with drop-oldest behavior on overflow. + _ = timeline.New(mainDB) + log.Printf("[INFO] Timeline storage subscriber started") + // Auth is handled at the Traefik layer (Google OAuth) — no in-app PIN auth. // Create load shedder — single source of truth for load shedding state diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 04d4532..d9c9a63 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -182,7 +182,8 @@ func isValidEventType(t string) bool { case "detection", "zone_entry", "zone_exit", "portal_crossing", "trigger_fired", "fall_alert", "FallDetected", "anomaly", "AnomalyDetected", "security_alert", "node_online", "node_offline", "ota_update", "baseline_changed", - "system", "learning_milestone", "sleep_session_end", "ZoneTransition": + "system", "learning_milestone", "sleep_session_end", "ZoneTransition", + "presence_transition", "stationary_detected", "anomaly_learned", "sleep_session_start": return true } return false @@ -193,7 +194,9 @@ func isValidEventType(t string) bool { // GET /api/events — paginated event list with FTS5 search and keyset cursor pagination. // // Query params: limit (default 50, max 500), before (timestamp_ms cursor), -// since (ISO8601), until (ISO8601), type, zone_id, person_id, q (FTS5 query), mode (expert|simple). +// since (ISO8601), until (ISO8601), type (single type filter), +// types (comma-separated type list for category filtering), +// zone, zone_id, person, person_id, q (FTS5 query), mode (expert|simple). // // GET /api/events/{id} — single event by ID. // @@ -268,6 +271,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { // Parse filters q := r.URL.Query().Get("q") eventType := r.URL.Query().Get("type") + typesStr := r.URL.Query().Get("types") // comma-separated list of event types zone := r.URL.Query().Get("zone") zoneID := r.URL.Query().Get("zone_id") if zoneID != "" { @@ -283,6 +287,22 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { untilStr := r.URL.Query().Get("until") // Upper bound timestamp mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple) + // Parse and validate types list (comma-separated) + var typesFilter []string + if typesStr != "" { + for _, t := range strings.Split(typesStr, ",") { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if !isValidEventType(t) { + writeJSONError(w, http.StatusBadRequest, "invalid event type: "+t) + return + } + typesFilter = append(typesFilter, t) + } + } + // Validate event type if eventType != "" && !isValidEventType(eventType) { writeJSONError(w, http.StatusBadRequest, "invalid event type") @@ -369,6 +389,16 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) { if eventType != "" { whereSQL += " AND " + p + "type = ?" whereArgs = append(whereArgs, eventType) + } else if len(typesFilter) > 0 { + // Multiple type filter (from category checkboxes) + ph := make([]string, len(typesFilter)) + for i := range ph { + ph[i] = "?" + } + whereSQL += " AND " + p + "type IN (" + strings.Join(ph, ", ") + ")" + for _, t := range typesFilter { + whereArgs = append(whereArgs, t) + } } else if isSimpleMode { // In simple mode with no explicit type filter, only show person-relevant event types // Build IN clause for simple mode types diff --git a/mothership/internal/api/events_test.go b/mothership/internal/api/events_test.go index 351caed..d4e6b22 100644 --- a/mothership/internal/api/events_test.go +++ b/mothership/internal/api/events_test.go @@ -286,6 +286,223 @@ func TestListEvents_InvalidType(t *testing.T) { } } +func TestListEvents_FilterByTypes(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + tests := []struct { + name string + types string + wantCount int + }{ + {"single type", "detection", 20}, + {"two types", "detection,zone_entry", 40}, + {"three types", "detection,zone_entry,zone_exit", 60}, + {"all five types", "detection,zone_entry,zone_exit,portal_crossing,system", 100}, + {"non-matching type", "fall_alert", 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/events?types="+tc.types+"&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + if resp.TotalFiltered != tc.wantCount { + t.Errorf("total_filtered = %d, want %d", resp.TotalFiltered, tc.wantCount) + } + + allowed := map[string]bool{} + for _, t := range strings.Split(tc.types, ",") { + allowed[t] = true + } + for _, ev := range resp.Events { + if !allowed[ev.Type] { + t.Errorf("event type = %q, not in types filter", ev.Type) + } + } + }) + } +} + +func TestListEvents_InvalidTypesParameter(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + tests := []struct { + name string + types string + }{ + {"single invalid", "bogus"}, + {"mixed valid and invalid", "detection,bogus"}, + {"invalid in middle", "detection,FAKE,zone_entry"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/events?types="+tc.types, nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for types=%q", w.Code, tc.types) + } + }) + } +} + +func TestListEvents_TypesPagination(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // Filter to detection + zone_entry = 40 events, paginate in pages of 15 + var allIDs []int64 + cursor := "" + page := 0 + + for { + url := "/api/events?types=detection,zone_entry&limit=15" + if cursor != "" { + url += "&before=" + cursor + } + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + for _, ev := range resp.Events { + allIDs = append(allIDs, ev.ID) + } + + page++ + if !resp.HasMore || page > 10 { + break + } + cursor = resp.Cursor + } + + // Should have 40 events total across pages + if len(allIDs) != 40 { + t.Errorf("total events across pages = %d, want 40", len(allIDs)) + } + + // No duplicate IDs + seen := map[int64]bool{} + for _, id := range allIDs { + if seen[id] { + t.Errorf("duplicate event ID %d in paginated results", id) + } + seen[id] = true + } +} + +func TestListEvents_TypesWithZoneAndPerson(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // detection (20, zone=Kitchen) + zone_entry (20, zone=Hallway) → zone=Kitchen filters to only detection = 20 + req := httptest.NewRequest("GET", "/api/events?types=detection,zone_entry&zone=Kitchen&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + // All detection events have zone=Kitchen (seed correlates type and zone by i%5) + if resp.TotalFiltered != 20 { + t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered) + } + + for _, ev := range resp.Events { + if ev.Type != "detection" && ev.Type != "zone_entry" { + t.Errorf("event type = %q, want detection or zone_entry", ev.Type) + } + if ev.Zone != "Kitchen" { + t.Errorf("event zone = %q, want Kitchen", ev.Zone) + } + } +} + +func TestListEvents_CombinedThreeFilters(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // detection (20 events) + zone=Kitchen (4 of those) + person=Alice (2 of those: i%5==0 → Alice, i%5==0 → Kitchen) + req := httptest.NewRequest("GET", "/api/events?type=detection&zone=Kitchen&person=Alice&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + // detection events where zone=Kitchen AND person=Alice + // seedEvents correlates type/zone/person by i%5: all detection events have zone=Kitchen, person=Alice + // So all 20 detection events match + if resp.TotalFiltered != 20 { + t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered) + } + + for _, ev := range resp.Events { + if ev.Type != "detection" { + t.Errorf("event type = %q, want detection", ev.Type) + } + if ev.Zone != "Kitchen" { + t.Errorf("event zone = %q, want Kitchen", ev.Zone) + } + if ev.Person != "Alice" { + t.Errorf("event person = %q, want Alice", ev.Person) + } + } +} + +func TestListEvents_TypesTakesPrecedenceOverSimpleMode(t *testing.T) { + h, cleanup := testEventsHandler(t) + defer cleanup() + + base := time.Now() + seedEvents(t, h, base, 100) + + // Simple mode normally excludes system events, but explicit types should override + req := httptest.NewRequest("GET", "/api/events?types=system,node_online&mode=simple&limit=100", nil) + w := httptest.NewRecorder() + h.listEvents(w, req) + + var resp eventsResponse + json.NewDecoder(w.Body).Decode(&resp) + + // Should return 20 system events (node_online doesn't exist in seeded data) + if resp.TotalFiltered != 20 { + t.Errorf("total_filtered = %d, want 20", resp.TotalFiltered) + } + + for _, ev := range resp.Events { + if ev.Type != "system" { + t.Errorf("event type = %q, want system", ev.Type) + } + } +} + func TestListEvents_FilterByZone(t *testing.T) { h, cleanup := testEventsHandler(t) defer cleanup()