feat(timeline): add search and filter to event timeline
Add server-side types filter (comma-separated) for category-based filtering, fuzzy text search with FTS5 fallback on Enter, and improved client-side filtering with character-sequence matching. Category checkboxes now send types to server for efficient loading. Includes table-driven tests for types filter, pagination, and combined filter scenarios. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a2fb948404
commit
efea321f19
4 changed files with 347 additions and 24 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue