mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user