| 1 | /** One term of a search box's query; every term must hold for an item to match. */ |
| 2 | export 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. */ |
| 12 | export const logField = (field: string) => field === "container" || field === "stream"; |
| 13 | |
| 14 | /** Short names for the request attributes Caddy records on every trace. */ |
| 15 | export 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`. */ |
| 24 | export const traceField = (field: string) => field in TRACE_ALIASES || field === "duration" || field.includes("."); |
| 25 | |
| 26 | export const TRACE_SORTS = ["newest", "oldest", "slowest", "fastest"] as const; |
| 27 | export 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 | */ |
| 33 | export 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. */ |
| 47 | export 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. */ |
| 58 | export const needles = (terms: Term[]) => terms.flatMap((term) => (term.field === null && !term.exclude ? [term.value] : [])); |