milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The Activity search parameters, validated as pure functions so the route's
|
||||
* `validateSearch` stays a one-liner and every rejection is testable without a
|
||||
* router.
|
||||
*
|
||||
* A search value arrives from a URL, from history state, or from a hand-typed
|
||||
* link, so nothing about its type is given. Anything that is not exactly the
|
||||
* value the API can filter on becomes `undefined`: an unbounded page is honest,
|
||||
* a page filtered on a coerced guess is not.
|
||||
*/
|
||||
|
||||
import type { QueriesFilter } from "@/lib/types";
|
||||
|
||||
export const ACTIVITY_MODES = ["history", "live"] as const;
|
||||
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
|
||||
|
||||
export interface ActivitySearch {
|
||||
mode: ActivityMode;
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
domain: string | undefined;
|
||||
client: string | undefined;
|
||||
blocked: boolean | undefined;
|
||||
}
|
||||
|
||||
/** History is the surface a bare `/activity` should open on: it answers questions. */
|
||||
export function validateMode(value: unknown): ActivityMode {
|
||||
return value === "live" ? "live" : "history";
|
||||
}
|
||||
|
||||
/**
|
||||
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
|
||||
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
|
||||
* string never passes, so `?since=now` cannot reach the API as garbage.
|
||||
*/
|
||||
export function validateTimestamp(value: unknown): number | undefined {
|
||||
return Number.isSafeInteger(value) ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocked filter. `false` is a real filter — "allowed only" — so it must
|
||||
* survive; only a genuine boolean does, because `"false"` out of a URL parser
|
||||
* that did not decode JSON would otherwise read as true.
|
||||
*/
|
||||
export function validateBlocked(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
|
||||
* the server treats an empty filter as no filter, and a URL that showed
|
||||
* `domain=` as applied state would claim a filter that is not filtering.
|
||||
*/
|
||||
export function validateText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API filter for a validated search, built field by field.
|
||||
*
|
||||
* Only the fields that are actually set are written, so an unfiltered request
|
||||
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
|
||||
* know, and the infinite query's cache key is the filter object, so a key
|
||||
* present-but-undefined and a key absent must not be two different windows onto
|
||||
* the same rows. `mode` never appears — it selects the surface, not the rows.
|
||||
*/
|
||||
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
|
||||
const filter: QueriesFilter = {};
|
||||
if (search.domain !== undefined) filter.domain = search.domain;
|
||||
if (search.client !== undefined) filter.client = search.client;
|
||||
if (search.blocked !== undefined) filter.blocked = search.blocked;
|
||||
if (search.since !== undefined) filter.since = search.since;
|
||||
if (search.until !== undefined) filter.until = search.until;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
|
||||
return {
|
||||
mode: validateMode(search["mode"]),
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
domain: validateText(search["domain"]),
|
||||
client: validateText(search["client"]),
|
||||
blocked: validateBlocked(search["blocked"]),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user