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 25745dd1..39247442 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx @@ -3,16 +3,17 @@ import { useState, useMemo } from "react"; import { EnrollmentEvent, Session } from "@/types"; import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers"; -import { buildEventSearchMatcher, parseEventSearchQuery } from "../utils/eventSearchQuery"; +import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../utils/eventSearchQuery"; import { getEnrichedOrLookup, formatErrorCode, errorCodeTooltip, type ErrorCodeInfo } from "@/utils/errorCodeMap"; import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance"; import { isRebootOrRetryClass } from "@/lib/installProgress"; import { formatDuration, formatUtcOffset } from "@/lib/formatting"; const SEARCH_SYNTAX_HINT = - "Searches event type, message and source. Several terms are combined with AND. " + + "Searches event type, message, source and the details JSON (e.g. gather output). " + + "Several terms are combined with AND; quote a phrase, e.g. \"Installation completed\". " + "A leading minus hides matches, e.g. -app_install_progress. " + - 'Quote a term to take it literally, e.g. "-1".'; + "Restrict a term to one field with type=, message=, source= or data=, e.g. data=hpia-log-collect."; interface EventTimelineProps { filteredEvents: EnrollmentEvent[]; @@ -67,9 +68,12 @@ export default function EventTimeline({ const [searchQuery, setSearchQuery] = useState(""); const [rawMode, setRawMode] = useState(false); - // Terms are AND-ed, a leading minus excludes — see utils/eventSearchQuery.ts. + // Terms are AND-ed, a leading minus excludes, key=value restricts — see utils/eventSearchQuery.ts. const matchesSearch = useMemo(() => buildEventSearchMatcher(searchQuery), [searchQuery]); - const excludedTerms = useMemo(() => parseEventSearchQuery(searchQuery).exclude, [searchQuery]); + const excludedTerms = useMemo( + () => parseEventSearchQuery(searchQuery).exclude.map(formatEventSearchTerm), + [searchQuery], + ); // The counter has to follow the search, otherwise excluding event types leaves it // reporting a number of rows the timeline no longer shows. diff --git a/src/Web/autopilot-monitor-web/app/sessions/utils/__tests__/eventSearchQuery.test.ts b/src/Web/autopilot-monitor-web/app/sessions/utils/__tests__/eventSearchQuery.test.ts index d0167dc1..b2a3738d 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/utils/__tests__/eventSearchQuery.test.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/utils/__tests__/eventSearchQuery.test.ts @@ -1,22 +1,50 @@ import { describe, it, expect } from "vitest"; import { buildEventSearchMatcher, + eventDataSearchText, + formatEventSearchTerm, parseEventSearchQuery, type EventSearchFields, } from "../eventSearchQuery"; -const ev = (eventType: string, message = "", source = "Agent"): EventSearchFields => ({ +const ev = ( + eventType: string, + message = "", + source = "Agent", + data?: Record, +): EventSearchFields => ({ eventType, message, source, + data, }); +// Payload shape of a gather-rule log-analysis event — the customer case: the text the +// user reads in the expanded event lives only here, not in the message. +const hpiaData = { + timestamp: "09.07.2026 04:16:33", + phase: "Installation", + exitcode: "0", + logLine: "09.07.2026 04:16:33 -- Installation completed. Exit code = 0.", + logLineNumber: 21, + logFile: "HP Image Assistant.log", + ruleId: "hpia-log-collect", + ruleTitle: "HP Updater Log Analyze", + exitCodeInfo: { + description: "The action completed successfully", + confidence: "high", + category: "msi", + symbol: "ERROR_SUCCESS", + }, +}; + const timeline: EventSearchFields[] = [ ev("app_install_started", "Installing Contoso Reader"), ev("app_install_progress", "Downloading 42%"), - ev("app_install_completed", "Contoso Reader installed"), + ev("app_install_completed", "Contoso Reader installed", "Agent", { exitCode: 0, errorCode: null }), ev("esp_provisioning_status", "DeviceSetup: Apps 3/7"), ev("enrollment_failed", "Apps failed with exit code -1", "DecisionEngine"), + ev("hpia_log_analyze", "HP Updater Log Analyze", "GatherRuleExecutor", hpiaData), ]; const visible = (query: string) => { @@ -28,26 +56,26 @@ const visible = (query: string) => { describe("parseEventSearchQuery", () => { it("splits whitespace-separated terms and lowercases them", () => { expect(parseEventSearchQuery("ESP Provisioning")).toEqual({ - include: ["esp", "provisioning"], + include: [{ text: "esp" }, { text: "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"], + include: [{ text: "error" }], + exclude: [{ text: "app_install_progress" }, { text: "perf" }], }); }); it("keeps a quoted minus literal — otherwise an exit code could not be searched", () => { - expect(parseEventSearchQuery('"-1"')).toEqual({ include: ["-1"], exclude: [] }); + expect(parseEventSearchQuery('"-1"')).toEqual({ include: [{ text: "-1" }], exclude: [] }); }); it("excludes a quoted phrase as one term", () => { expect(parseEventSearchQuery('-"exit code 1"')).toEqual({ include: [], - exclude: ["exit code 1"], + exclude: [{ text: "exit code 1" }], }); }); @@ -57,10 +85,62 @@ describe("parseEventSearchQuery", () => { it("de-duplicates repeated terms", () => { expect(parseEventSearchQuery("app app -perf -perf")).toEqual({ - include: ["app"], - exclude: ["perf"], + include: [{ text: "app" }], + exclude: [{ text: "perf" }], }); }); + + it("maps a known qualifier to its field, case-insensitively, with = or :", () => { + expect(parseEventSearchQuery('TYPE=app_install source:"Decision Engine" -data=hpia content=x')).toEqual({ + include: [ + { text: "app_install", field: "eventType" }, + { text: "decision engine", field: "source" }, + { text: "x", field: "data" }, + ], + exclude: [{ text: "hpia", field: "data" }], + }); + }); + + it("keeps an unknown qualifier as a literal term", () => { + expect(parseEventSearchQuery("14:30 foo=bar")).toEqual({ + include: [{ text: "14:30" }, { text: "foo=bar" }], + exclude: [], + }); + }); + + it("treats the same text with and without a qualifier as two different terms", () => { + expect(parseEventSearchQuery("apps type=apps")).toEqual({ + include: [{ text: "apps" }, { text: "apps", field: "eventType" }], + exclude: [], + }); + }); +}); + +describe("formatEventSearchTerm", () => { + it("renders the canonical qualifier for the hiding chip", () => { + expect(formatEventSearchTerm({ text: "perf" })).toBe("perf"); + expect(formatEventSearchTerm({ text: "ime", field: "source" })).toBe("source=ime"); + expect(formatEventSearchTerm({ text: "x", field: "eventType" })).toBe("type=x"); + expect(formatEventSearchTerm({ text: "x", field: "data" })).toBe("data=x"); + }); +}); + +describe("eventDataSearchText", () => { + it("joins every leaf value, lowercased, one per line — keys are not included", () => { + const text = eventDataSearchText({ exitCode: 0, nested: { symbol: "ERROR_SUCCESS" }, list: ["a", 1, true], none: null }); + expect(text.split("\n")).toEqual(["0", "error_success", "a", "1", "true"]); + expect(text).not.toContain("exitcode"); + }); + + it("parses nested JSON strings the way the details view renders them", () => { + const text = eventDataSearchText({ output: '{"status":"Installation completed"}' }); + expect(text).toContain("installation completed"); + }); + + it("is empty without a payload", () => { + expect(eventDataSearchText(undefined)).toBe(""); + expect(eventDataSearchText(null)).toBe(""); + }); }); describe("buildEventSearchMatcher", () => { @@ -68,6 +148,7 @@ describe("buildEventSearchMatcher", () => { expect(buildEventSearchMatcher("")).toBeNull(); expect(buildEventSearchMatcher(" ")).toBeNull(); expect(buildEventSearchMatcher("-")).toBeNull(); + expect(buildEventSearchMatcher("type=")).toBeNull(); }); it("matches event type, message and source", () => { @@ -76,17 +157,24 @@ describe("buildEventSearchMatcher", () => { expect(visible("contoso")).toEqual(["app_install_started", "app_install_completed"]); }); + it("finds text that only exists in the details payload — the customer's 'Installation' case", () => { + expect(visible("Installation")).toEqual(["hpia_log_analyze"]); + expect(visible('"Installation completed"')).toEqual(["hpia_log_analyze"]); + expect(visible("ERROR_SUCCESS")).toEqual(["hpia_log_analyze"]); + }); + 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", + "hpia_log_analyze", ]); }); it("treats a partial type name as a prefix filter over the whole family", () => { - expect(visible("-app_install")).toEqual(["esp_provisioning_status", "enrollment_failed"]); + expect(visible("-app_install")).toEqual(["esp_provisioning_status", "enrollment_failed", "hpia_log_analyze"]); }); it("combines a search term with exclusions", () => { @@ -98,12 +186,15 @@ describe("buildEventSearchMatcher", () => { "app_install_started", "app_install_completed", ]); + expect(visible("gatherruleexecutor completed")).toEqual(["hpia_log_analyze"]); }); 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([]); + // Two adjacent payload values, likewise. + expect(visible("installation0")).toEqual([]); }); it("is case-insensitive on both sides", () => { @@ -116,4 +207,39 @@ describe("buildEventSearchMatcher", () => { "app_install_completed", ]); }); + + it("restricts a qualified term to its field", () => { + // "installation" appears in the message of app_install_started only via "Installing" — not a match; + // in the payload it is a match. type= must not look at either. + expect(visible("type=installation")).toEqual([]); + expect(visible("data=installation")).toEqual(["hpia_log_analyze"]); + expect(visible("content=installation")).toEqual(["hpia_log_analyze"]); + expect(visible("message=installing")).toEqual(["app_install_started"]); + expect(visible("source=decision")).toEqual(["enrollment_failed"]); + // "apps" is in messages, not in any event type. + expect(visible("type=apps")).toEqual([]); + expect(visible("message=apps")).toEqual(["esp_provisioning_status", "enrollment_failed"]); + }); + + it("restricts a qualified exclusion to its field", () => { + // Free-text -completed would also hide hpia_log_analyze (payload); type= keeps it. + expect(visible("-type=completed")).toEqual([ + "app_install_started", + "app_install_progress", + "esp_provisioning_status", + "enrollment_failed", + "hpia_log_analyze", + ]); + expect(visible("-completed")).toEqual([ + "app_install_started", + "app_install_progress", + "esp_provisioning_status", + "enrollment_failed", + ]); + }); + + it("does not match payload keys — an errorCode: null field is invisible to -error", () => { + expect(visible("errorcode")).toEqual([]); + expect(visible("-error")).toContain("app_install_completed"); + }); }); diff --git a/src/Web/autopilot-monitor-web/app/sessions/utils/eventSearchQuery.ts b/src/Web/autopilot-monitor-web/app/sessions/utils/eventSearchQuery.ts index 13a0bac1..76e02f73 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/utils/eventSearchQuery.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/utils/eventSearchQuery.ts @@ -2,54 +2,142 @@ // // 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 +// exclusion. Both sides match the same fields — eventType, message, source and the +// values of the structured `data` payload (the JSON under the expanded event) — 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) +// esp provisioning events matching BOTH terms (each in any of the fields) +// "Installation completed" a phrase — found even when it only appears in the details JSON // -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 +// type=app_install restricted to the event type +// data=hpia-log-collect restricted to the details payload (alias: content=) +// -source=ImeLogTracker an exclusion restricted to one field // // Matching is case-insensitive substring, so a partial type name such as // `-app_install` hides every app_install_* event. The tokenizer is shared with the -// session search (lib/searchQueryTokens); field qualifiers (`key=value`) exist only -// there — here the token stays a literal term. +// session search (lib/searchQueryTokens); a qualifier that is not in +// EVENT_SEARCH_QUALIFIERS stays a literal term (`14:30`, `foo=bar`). +// +// The payload is searched by its VALUES only, never by its keys — otherwise `-error` +// would hide every event that merely carries an `errorCode: null` field. Nested JSON +// strings are parsed first, exactly as the details view does, so what the user reads +// in the expanded event is what a term can match. import { literalText, tokenizeSearchQuery } from "@/lib/searchQueryTokens"; +import { normalizeJsonLikeValue } from "./eventHelpers"; /** The fields a search term is matched against. Structural subset of EnrollmentEvent. */ export interface EventSearchFields { eventType?: string | null; message?: string | null; source?: string | null; + data?: Record | null; +} + +export type EventSearchFieldKey = keyof EventSearchFields; + +/** The word in front of `=` / `:` → the one field the term may match. Lowercase. */ +export const EVENT_SEARCH_QUALIFIERS: Readonly> = { + type: "eventType", + message: "message", + source: "source", + data: "data", + content: "data", +}; + +export interface EventSearchTerm { + /** Lowercased. */ + text: string; + /** Set for a qualified term: the one field it may match. */ + field?: EventSearchFieldKey; } export interface ParsedEventSearchQuery { - /** Terms that must all match. Lowercased. */ - include: string[]; - /** Terms that must not match. Lowercased. */ - exclude: string[]; + /** Terms that must all match. */ + include: EventSearchTerm[]; + /** Terms that must not match. */ + exclude: EventSearchTerm[]; } export function parseEventSearchQuery(query: string): ParsedEventSearchQuery { - const include: string[] = []; - const exclude: string[] = []; + const include: EventSearchTerm[] = []; + const exclude: EventSearchTerm[] = []; for (const token of tokenizeSearchQuery(query)) { - const text = literalText(token).toLowerCase(); + const field = token.qualifier === undefined ? undefined : EVENT_SEARCH_QUALIFIERS[token.qualifier.toLowerCase()]; + const term: EventSearchTerm = field + ? { text: token.text.toLowerCase(), field } + : { text: literalText(token).toLowerCase() }; const target = token.negated ? exclude : include; - if (!target.includes(text)) target.push(text); + if (!target.some(t => t.text === term.text && t.field === term.field)) target.push(term); } return { include, exclude }; } +/** How a term reads for the "hiding …" chip: `source=x` for a qualified term, `x` otherwise. */ +export function formatEventSearchTerm(term: EventSearchTerm): string { + if (!term.field) return term.text; + const qualifier = Object.entries(EVENT_SEARCH_QUALIFIERS).find(([, key]) => key === term.field)?.[0] ?? term.field; + return `${qualifier}=${term.text}`; +} + +function collectLeafValues(value: unknown, out: string[]): void { + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + for (const item of value) collectLeafValues(item, out); + return; + } + if (typeof value === "object") { + for (const item of Object.values(value)) collectLeafValues(item, out); + return; + } + out.push(String(value)); +} + +// The payload text is derived once per data object, not once per keystroke: the matcher +// is rebuilt on every query change but the event objects stay the same. +const dataTextCache = new WeakMap(); + +/** + * The searchable text of an event's payload: every leaf value, lowercased, newline-joined + * so a term can never match across two values. Empty when there is no payload. + */ +export function eventDataSearchText(data: Record | null | undefined): string { + if (!data) return ""; + const cached = dataTextCache.get(data); + if (cached !== undefined) return cached; + + const values: string[] = []; + collectLeafValues(normalizeJsonLikeValue(data), values); + const text = values.join("\n").toLowerCase(); + dataTextCache.set(data, text); + return text; +} + +function fieldText(event: EventSearchFields, field: EventSearchFieldKey): string { + if (field === "data") return eventDataSearchText(event.data); + return (event[field] ?? "").toLowerCase(); +} + +const ALL_FIELDS: readonly EventSearchFieldKey[] = ["eventType", "message", "source", "data"]; + +function termMatches(term: EventSearchTerm, event: EventSearchFields): boolean { + if (term.field) return fieldText(event, term.field).includes(term.text); + // Field by field, 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. + return ALL_FIELDS.some(field => fieldText(event, field).includes(term.text)); +} + /** * 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. + * (empty, whitespace, a lone minus or a bare `type=` 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, @@ -57,10 +145,6 @@ export function buildEventSearchMatcher( 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)); - }; + return (event: EventSearchFields) => + include.every(t => termMatches(t, event)) && !exclude.some(t => termMatches(t, event)); }