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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-25 14:33:05 +02:00
parent c92fe7c2a0
commit f22feb71ff
3 changed files with 251 additions and 10 deletions
@@ -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({
</button>
);
})}
<span className="text-xs text-gray-400">({filteredEvents.length}/{events.length})</span>
<span className="text-xs text-gray-400">({searchVisibleCount}/{events.length})</span>
{excludedTerms.length > 0 && (
<span
className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 border border-gray-300 text-gray-600 max-w-[16rem] truncate"
title={`Events matching ${excludedTerms.map(t => `"${t}"`).join(", ")} are hidden.`}
>
hiding {excludedTerms.join(", ")}
</span>
)}
<div className="flex gap-1.5 ml-auto items-center">
<button
onClick={() => setRawMode(!rawMode)}
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
buildEventSearchMatcher,
parseEventSearchQuery,
type EventSearchFields,
} from "../eventSearchQuery";
const ev = (eventType: string, message = "", source = "Agent"): EventSearchFields => ({
eventType,
message,
source,
});
const timeline: EventSearchFields[] = [
ev("app_install_started", "Installing Contoso Reader"),
ev("app_install_progress", "Downloading 42%"),
ev("app_install_completed", "Contoso Reader installed"),
ev("esp_provisioning_status", "DeviceSetup: Apps 3/7"),
ev("enrollment_failed", "Apps failed with exit code -1", "DecisionEngine"),
];
const visible = (query: string) => {
const matcher = buildEventSearchMatcher(query);
const rows = matcher ? timeline.filter(matcher) : timeline;
return rows.map(e => e.eventType);
};
describe("parseEventSearchQuery", () => {
it("splits whitespace-separated terms and lowercases them", () => {
expect(parseEventSearchQuery("ESP Provisioning")).toEqual({
include: ["esp", "provisioning"],
exclude: [],
});
});
it("reads a leading minus as an exclusion", () => {
expect(parseEventSearchQuery("error -app_install_progress -perf")).toEqual({
include: ["error"],
exclude: ["app_install_progress", "perf"],
});
});
it("keeps a quoted minus literal — otherwise an exit code could not be searched", () => {
expect(parseEventSearchQuery('"-1"')).toEqual({ include: ["-1"], exclude: [] });
});
it("excludes a quoted phrase as one term", () => {
expect(parseEventSearchQuery('-"exit code 1"')).toEqual({
include: [],
exclude: ["exit code 1"],
});
});
it("drops a lone minus so the timeline does not blank out mid-typing", () => {
expect(parseEventSearchQuery("-")).toEqual({ include: [], exclude: [] });
});
it("de-duplicates repeated terms", () => {
expect(parseEventSearchQuery("app app -perf -perf")).toEqual({
include: ["app"],
exclude: ["perf"],
});
});
});
describe("buildEventSearchMatcher", () => {
it("returns null when there is nothing to filter on", () => {
expect(buildEventSearchMatcher("")).toBeNull();
expect(buildEventSearchMatcher(" ")).toBeNull();
expect(buildEventSearchMatcher("-")).toBeNull();
});
it("matches event type, message and source", () => {
const matcher = buildEventSearchMatcher("decisionengine")!;
expect(timeline.filter(matcher).map(e => e.eventType)).toEqual(["enrollment_failed"]);
expect(visible("contoso")).toEqual(["app_install_started", "app_install_completed"]);
});
it("hides every match of an excluded term", () => {
expect(visible("-app_install_progress")).toEqual([
"app_install_started",
"app_install_completed",
"esp_provisioning_status",
"enrollment_failed",
]);
});
it("treats a partial type name as a prefix filter over the whole family", () => {
expect(visible("-app_install")).toEqual(["esp_provisioning_status", "enrollment_failed"]);
});
it("combines a search term with exclusions", () => {
expect(visible("apps -esp")).toEqual(["enrollment_failed"]);
});
it("ANDs multiple search terms across different fields", () => {
expect(visible("app_install contoso")).toEqual([
"app_install_started",
"app_install_completed",
]);
});
it("never matches a term across a field boundary", () => {
// "started" ends eventType, "Installing" opens message — a naive concatenation
// would let the joined string match.
expect(visible("startedinstalling")).toEqual([]);
});
it("is case-insensitive on both sides", () => {
expect(visible("-APP_INSTALL_PROGRESS")).not.toContain("app_install_progress");
});
it("lets an exclusion win over a search term that also matches", () => {
expect(visible("app_install -progress")).toEqual([
"app_install_started",
"app_install_completed",
]);
});
});
@@ -0,0 +1,105 @@
// Search syntax for the Event Timeline search box.
//
// Follows the convention every search box shares (Google, GitHub, Gmail, Jira):
// whitespace-separated terms are AND-ed, a leading minus turns a term into an
// exclusion. Both sides match the same fields — eventType, message, source — so
// `-x` is exactly the negation of searching for `x`, with no second rule to learn.
//
// error events matching "error"
// esp provisioning events matching BOTH terms (in any of the three fields)
// -app_install_progress everything EXCEPT matches of that term
// error -heartbeat combined
// "-1" quoted: a literal minus, not an exclusion
// -"exit code 1" excludes the whole phrase
//
// Matching is case-insensitive substring, so a partial type name such as
// `-app_install` hides every app_install_* event.
/** The fields a search term is matched against. Structural subset of EnrollmentEvent. */
export interface EventSearchFields {
eventType?: string | null;
message?: string | null;
source?: string | null;
}
export interface ParsedEventSearchQuery {
/** Terms that must all match. Lowercased. */
include: string[];
/** Terms that must not match. Lowercased. */
exclude: string[];
}
interface Token {
text: string;
negated: boolean;
}
// Hand-rolled so a quote can protect a leading minus: the negation is decided at
// the first character of a token, before any quote is consumed, which makes `"-1"`
// a literal search term while `-"foo bar"` excludes the phrase.
function tokenize(query: string): Token[] {
const tokens: Token[] = [];
let current: Token | null = null;
let inQuotes = false;
const flush = () => {
if (current && current.text !== "") tokens.push(current);
current = null;
};
for (const ch of query) {
if (!inQuotes && /\s/.test(ch)) {
flush();
continue;
}
if (ch === '"') {
current ??= { text: "", negated: false };
inQuotes = !inQuotes;
continue;
}
if (current === null) {
// First character of a token: a bare minus opens an exclusion.
if (ch === "-") {
current = { text: "", negated: true };
continue;
}
current = { text: "", negated: false };
}
current.text += ch;
}
flush();
return tokens;
}
export function parseEventSearchQuery(query: string): ParsedEventSearchQuery {
const include: string[] = [];
const exclude: string[] = [];
for (const token of tokenize(query)) {
const text = token.text.toLowerCase();
const target = token.negated ? exclude : include;
if (!target.includes(text)) target.push(text);
}
return { include, exclude };
}
/**
* Predicate for the parsed query, or null when the query carries no filter at all
* (empty, whitespace, or a lone minus while the user is still typing) — callers use
* null to skip filtering entirely rather than filtering with an always-true predicate.
*/
export function buildEventSearchMatcher(
query: string,
): ((event: EventSearchFields) => boolean) | null {
const { include, exclude } = parseEventSearchQuery(query);
if (include.length === 0 && exclude.length === 0) return null;
return (event: EventSearchFields) => {
// Newline-joined so a term can never match across a field boundary, while a
// multi-term query is still free to satisfy its terms from different fields.
const haystack = `${event.eventType ?? ""}\n${event.message ?? ""}\n${event.source ?? ""}`.toLowerCase();
return include.every(t => haystack.includes(t)) && !exclude.some(t => haystack.includes(t));
};
}