From f22feb71ffd3aa1b2a0ad73f0d173e6c9364dfe1 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 25 Aug 2026 14:33:05 +0200 Subject: [PATCH] feat(web): exclude events from the session timeline search The timeline search box now tokenizes the query instead of treating it as a single substring: terms are AND-ed and a leading minus hides its matches (e.g. -app_install_progress), following the convention every other search box uses. Quoting keeps a term literal, so an exit code like "-1" stays searchable. The negation is decided at a token's first character, before a quote is consumed, and terms match the same fields as the positive search (event type, message, source) joined by a newline so a term can never match across a field boundary. The event counter follows the search and an active exclusion is named in a pill next to the severity filters. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/sessions/components/EventTimeline.tsx | 37 ++++-- .../utils/__tests__/eventSearchQuery.test.ts | 119 ++++++++++++++++++ .../app/sessions/utils/eventSearchQuery.ts | 105 ++++++++++++++++ 3 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/sessions/utils/__tests__/eventSearchQuery.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/utils/eventSearchQuery.ts diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx index a3feda3d..d2a2d87c 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx @@ -3,10 +3,16 @@ import { useState, useMemo } from "react"; import { EnrollmentEvent, Session } from "@/types"; import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers"; +import { buildEventSearchMatcher, parseEventSearchQuery } from "../utils/eventSearchQuery"; import { getEnrichedOrLookup, formatErrorCode, type ErrorCodeEntry } from "@/utils/errorCodeMap"; import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance"; import { formatDuration, formatUtcOffset } from "@/lib/formatting"; +const SEARCH_SYNTAX_HINT = + "Searches event type, message and source. Several terms are combined with AND. " + + "A leading minus hides matches, e.g. -app_install_progress. " + + 'Quote a term to take it literally, e.g. "-1".'; + interface EventTimelineProps { filteredEvents: EnrollmentEvent[]; events: EnrollmentEvent[]; @@ -60,14 +66,16 @@ export default function EventTimeline({ const [searchQuery, setSearchQuery] = useState(""); const [rawMode, setRawMode] = useState(false); - const matchesSearch = useMemo(() => { - if (!searchQuery.trim()) return null; - const q = searchQuery.toLowerCase(); - return (event: EnrollmentEvent) => - event.eventType?.toLowerCase().includes(q) || - event.message?.toLowerCase().includes(q) || - event.source?.toLowerCase().includes(q); - }, [searchQuery]); + // Terms are AND-ed, a leading minus excludes — see utils/eventSearchQuery.ts. + const matchesSearch = useMemo(() => buildEventSearchMatcher(searchQuery), [searchQuery]); + const excludedTerms = useMemo(() => parseEventSearchQuery(searchQuery).exclude, [searchQuery]); + + // The counter has to follow the search, otherwise excluding event types leaves it + // reporting a number of rows the timeline no longer shows. + const searchVisibleCount = useMemo( + () => (matchesSearch ? filteredEvents.filter(matchesSearch).length : filteredEvents.length), + [filteredEvents, matchesSearch], + ); const sortedBySequence = useMemo(() => { let filtered = events.filter(e => severityFilters.has(e.severity)); @@ -102,7 +110,8 @@ export default function EventTimeline({ type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder="Search events..." + placeholder="Search or -exclude..." + title={SEARCH_SYNTAX_HINT} className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500" /> {searchQuery && ( @@ -139,7 +148,15 @@ export default function EventTimeline({ ); })} - ({filteredEvents.length}/{events.length}) + ({searchVisibleCount}/{events.length}) + {excludedTerms.length > 0 && ( + `"${t}"`).join(", ")} are hidden.`} + > + hiding {excludedTerms.join(", ")} + + )}