1/** One term of a search box's query; every term must hold for an item to match. */
2export interface Term {
3 /** Null for free text, which matches anywhere in the item's text. */
4 field: string | null;
5 /** `:` matches a field starting with `value`; the rest compare numbers. */
6 op: ":" | ">" | ">=" | "<" | "<=";
7 value: string;
8 exclude: boolean;
9}
10
11/** Fields a log search can name; any other `word:` stays free text, since log lines are full of colons. */
12export const logField = (field: string) => field === "container" || field === "stream";
13
14/** Short names for the request attributes Caddy records on every trace. */
15export const TRACE_ALIASES: Record<string, string> = {
16 status: "http.response.status_code",
17 method: "http.request.method",
18 path: "url.path",
19 host: "server.address",
20 client: "client.address",
21};
22
23/** Beside the aliases and `duration`, any dotted name is taken for a span attribute, like `http.route`. */
24export const traceField = (field: string) => field in TRACE_ALIASES || field === "duration" || field.includes(".");
25
26export const TRACE_SORTS = ["newest", "oldest", "slowest", "fastest"] as const;
27export type TraceSort = (typeof TRACE_SORTS)[number];
28
29/**
30 * Splits a search into terms: `-` excludes one, `"quoted text"` keeps its spaces, `field:value` matches a field that
31 * starts with the value (`status:5xx` reads as `status:5`), and `field:>value` compares.
32 */
33export function parseSearch(query: string, known: (field: string) => boolean): Term[] {
34 const terms: Term[] = [];
35 for (const match of query.matchAll(/(-?)(?:([\w.]+):(>=|<=|>|<)?)?(?:"([^"]*)"?|(\S+))/g)) {
36 const [raw, minus, field, op, quoted, bare] = match;
37 const value = (quoted ?? bare ?? "").replace(/^(\d+)x+$/i, "$1");
38 if (!value) continue;
39 terms.push(field && known(field)
40 ? { field, op: (op ?? ":") as Term["op"], value, exclude: !!minus }
41 : { field: null, op: ":", value: field ? raw.slice(minus!.length) : value, exclude: !!minus });
42 }
43 return terms;
44}
45
46/** Whether `value` satisfies `term`, ignoring `exclude`; text compares without case. */
47export function matches(term: Term, value: string | undefined) {
48 if (value === undefined) return false;
49 if (term.op !== ":") {
50 const [a, b] = [Number(value), Number(term.value)];
51 return term.op === ">" ? a > b : term.op === ">=" ? a >= b : term.op === "<" ? a < b : a <= b;
52 }
53 const [text, needle] = [value.toLowerCase(), term.value.toLowerCase()];
54 return term.field === null ? text.includes(needle) : text.startsWith(needle);
55}
56
57/** The free text a search looks for, to mark where it's found. */
58export const needles = (terms: Term[]) => terms.flatMap((term) => (term.field === null && !term.exclude ? [term.value] : []));