Files
nxdns/admin/src/features/activity/search.ts
T
mokhtar b774b05456 activity: select-only multi-client filter
the freetype client field is gone. the picker is a select-only menu of known clients with multi-select, chips for the active set, and a 32-client cap shared with the server. the api accepts a comma-separated client list and filters any-of with bound parameters. the trigger carets come from phosphor icons, newly adopted. bundle budget rises to 900000 bytes for the picker and the icon dependency.
2026-08-31 17:40:07 +02:00

120 lines
4.8 KiB
TypeScript

/**
* 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;
}
/** `queries_repo.max_clients`: past this the API answers 400 rather than filter. */
export const MAX_CLIENTS = 32;
/**
* The client filter: a comma-separated list of exact addresses, canonicalized
* here and nowhere else.
*
* This is the one place the value is read, so it is the one place it can be made
* to mean exactly one thing. Everything downstream — the chips that show the
* filter and the request that applies it — reads what this returns, so the two
* cannot disagree about a link somebody pasted.
*
* The rule: entries are trimmed, blanks are dropped, repeats are dropped, and
* the list is cut to the cap. A blank entry names no client and a repeat asks
* for the same client twice, so neither changes which rows come back; dropping
* them is not a different filter, it is the same filter written once. The cut is
* a different filter, and it is the honest one available: the API refuses a
* longer list outright, so keeping the extra addresses would show a filter that
* cannot be applied at all.
*/
export function validateClients(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const seen = new Set<string>();
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (trimmed !== "") seen.add(trimmed);
if (seen.size === MAX_CLIENTS) break;
}
return seen.size === 0 ? undefined : [...seen].join(",");
}
/**
* 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: validateClients(search["client"]),
blocked: validateBlocked(search["blocked"]),
};
}