mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
Agent Host changes for agents/timeline-search-enhancement (#150)
This commit is contained in:
committed by
GitHub
parent
844d86410c
commit
4df52b3da9
@@ -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.
|
||||
|
||||
+136
-10
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user