Agent Host changes for agents/timeline-search-enhancement (#150)

This commit is contained in:
Oliver Kieselbach
2026-09-08 14:47:30 +02:00
committed by GitHub
parent 844d86410c
commit 4df52b3da9
3 changed files with 249 additions and 35 deletions
@@ -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.
@@ -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<string, unknown>,
): 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");
});
});
@@ -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<string, unknown> | 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<Record<string, EventSearchFieldKey>> = {
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<object, string>();
/**
* 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<string, unknown> | 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));
}