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

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:
2026-08-22 10:52:56 +02:00
parent 0fd6bbd312
commit fa323c7ed4
42 changed files with 3225 additions and 1426 deletions
+95
View File
@@ -0,0 +1,95 @@
/**
* The bridge between a `datetime-local` input and the unix seconds the URL and
* the API speak.
*
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
* fall-back fold gives two instants the same text, and a spring-forward gap
* gives an hour of text no instant at all. So the text is never the authority:
* a bound the operator did not touch is carried through as the number it
* already was, and a bound they did edit is accepted only when it survives a
* round trip unchanged.
*/
/** Zero-padded to the width the `datetime-local` grammar requires. */
function pad(value: number, width: number): string {
return String(value).padStart(width, "0");
}
/**
* Unix seconds → the local wall-clock text a `datetime-local` input holds,
* always with seconds, because the inputs run at `step={1}`.
*/
export function unixToDatetimeLocal(unix: number): string {
const date = new Date(unix * 1000);
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
return `${day}T${time}`;
}
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
/**
* The text with its seconds spelled out, or undefined when it is not a
* `datetime-local` value at all. A browser omits `:00` seconds even at
* `step={1}`, so the canonical form is what a round trip compares against.
*
* The grammar allows a fractional part after the seconds and some engines emit
* one; a bound is a whole second here and on the wire, so it is parsed and then
* dropped rather than treated as text we do not recognise.
*/
function canonicalize(value: string): string | undefined {
const match = DATETIME_LOCAL.exec(value);
if (match === null) return undefined;
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
}
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
export function datetimeLocalToUnix(value: string): number | undefined {
const canonical = canonicalize(value);
if (canonical === undefined) return undefined;
const ms = new Date(canonical).getTime();
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
}
/**
* One bound of the filter form: what the input shows, what the applied search
* carried, and whether the operator has touched it since.
*/
export interface DatetimeField {
text: string;
/** The applied value this field was seeded from, reused while `dirty` is false. */
original: number | undefined;
dirty: boolean;
}
export type DatetimeResolution =
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
export function datetimeField(original: number | undefined): DatetimeField {
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
}
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
return { ...field, text, dirty: true };
}
/**
* The unix value this bound applies.
*
* An untouched field resolves to the number it was seeded with, never to a
* re-parse of its own text: the text of a fall-back instant names two of them,
* and re-parsing would silently move a bound the operator never edited.
*
* An edited field is parsed, then formatted back. A wall-clock time inside the
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
* an hour; the round trip catches that and the caller reports it instead of
* filtering on an hour nobody asked for.
*/
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
if (!field.dirty) return { ok: true, value: field.original };
if (field.text.trim() === "") return { ok: true, value: undefined };
const unix = datetimeLocalToUnix(field.text);
if (unix === undefined) return { ok: false, reason: "unparseable" };
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
return { ok: true, value: unix };
}