admin: overview redesign, device scope, one formatting contract (milestone 39)
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s

The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only.

GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry.

All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else.

Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash.

Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round.

Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
This commit is contained in:
2026-09-07 23:11:50 +02:00
parent e656670dd4
commit 85b8be50a0
77 changed files with 2869 additions and 1163 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Coverage } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
import { colors, metrics } from "@/ui/tokens.stylex";
const styles = stylex.create({
notice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
+2 -1
View File
@@ -3,6 +3,7 @@ import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { formatDuration } from "@/lib/format";
const styles = stylex.create({
message: {
@@ -45,7 +46,7 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
if (error.status === 429) {
message =
remaining !== null && remaining > 0
? `Rate limited. Try again in ${remaining}s.`
? `Rate limited. Try again in ${formatDuration(remaining)}.`
: "Rate limited. Try again.";
} else if (error.status === 503) {
message = "The server is starting or degraded. Try again shortly.";
+3 -2
View File
@@ -119,8 +119,9 @@ export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/ap
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
/** Every Overview panel for one window, from one read transaction. */
export const getOverview = (period?: Period): Promise<Overview> => request(`/api/overview${qs({ period })}`);
/** Every Overview panel for one window, from one read transaction; `client` scopes it to one device. */
export const getOverview = (period?: Period, client?: string): Promise<Overview> =>
request(`/api/overview${qs({ period, client })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
+77 -13
View File
@@ -1,8 +1,17 @@
import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
import {
formatBytes,
formatClock,
formatCount,
formatDuration,
formatMicros,
formatPercent,
formatRate,
formatTime,
} from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
expect(formatTime(1704067200, "en-US", "UTC").replace(/ /g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
expect(formatTime(1704067200, "en-US", "UTC").replace(/\u202f/g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
});
test("formatClock states the time of day alone, for a stamp read against now", () => {
@@ -12,6 +21,33 @@ test("formatClock states the time of day alone, for a stamp read against now", (
expect(formatClock(Date.UTC(2026, 0, 1, 14, 5) / 1000, "en-GB", "UTC")).not.toMatch(/2026/);
});
test("formatCount groups thousands", () => {
expect(formatCount(0)).toBe("0");
expect(formatCount(999)).toBe("999");
expect(formatCount(18432)).toBe("18,432");
expect(formatCount(1_234_567)).toBe("1,234,567");
});
test("formatPercent always carries two decimals, rounded half up", () => {
expect(formatPercent(0)).toBe("0.00%");
expect(formatPercent(1)).toBe("100.00%");
expect(formatPercent(0.1544)).toBe("15.44%");
expect(formatPercent(0.154)).toBe("15.40%");
// A stored 15.435 sits just under the half; it still rounds up.
expect(formatPercent(0.15435)).toBe("15.44%");
expect(formatPercent(0.15434)).toBe("15.43%");
expect(formatPercent(0.005)).toBe("0.50%");
expect(formatPercent(0.00005)).toBe("0.01%");
expect(formatPercent(0.00004)).toBe("0.00%");
});
test("formatRate carries one decimal, rounded half up", () => {
expect(formatRate(12.84)).toBe("12.8");
expect(formatRate(12.85)).toBe("12.9");
expect(formatRate(5)).toBe("5.0");
expect(formatRate(0)).toBe("0.0");
});
test("formatBytes humanizes with binary units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(1023)).toBe("1023 B");
@@ -22,21 +58,25 @@ test("formatBytes humanizes with binary units", () => {
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
});
test("formatDuration steps up a unit at each boundary and truncates", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(59)).toBe("59s");
test("formatDuration is the two largest nonzero units, unpadded", () => {
expect(formatDuration(45)).toBe("45s");
expect(formatDuration(60)).toBe("1m");
expect(formatDuration(3599)).toBe("59m");
expect(formatDuration(725)).toBe("12m 5s");
expect(formatDuration(3600)).toBe("1h");
expect(formatDuration(10800)).toBe("3h");
expect(formatDuration(86399)).toBe("23h");
expect(formatDuration(86400)).toBe("1d");
expect(formatDuration(400000)).toBe("4d");
expect(formatDuration(3600 * 4 + 60 * 12 + 30)).toBe("4h 12m");
expect(formatDuration(86400 * 6 + 3600 * 4)).toBe("6d 4h");
// The two largest that are nonzero, whatever sits between them.
expect(formatDuration(86400 * 6 + 5)).toBe("6d 5s");
expect(formatDuration(86400 * 6)).toBe("6d");
expect(formatDuration(86400 * 400)).toBe("400d");
// Never "06d 4h" or "6d 04h".
expect(formatDuration(86400 * 6 + 3600 * 4)).not.toMatch(/0\d/);
});
test("formatDuration is never negative", () => {
// Clock skew between the server's timestamps and the browser's clock.
expect(formatDuration(-5)).toBe("0s");
test("formatDuration reads under a second as such, skew included", () => {
expect(formatDuration(0)).toBe("<1s");
expect(formatDuration(0.9)).toBe("<1s");
expect(formatDuration(-5)).toBe("<1s");
});
test("formatMicros renders milliseconds with one decimal", () => {
@@ -45,3 +85,27 @@ test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(999)).toBe("1.0 ms");
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
});
/**
* The contract's teeth, as far as a text scan can bite: no source outside this
* module calls a number-formatting API. A bare `{count}` in JSX is invisible to
* this scan and is caught in review; the scan closes the door on the four ways
* a competing rule would be written. `datetime-local` zero-padding in
* `activity/datetime.ts` is a wire grammar, not a display rule, and is the one
* allowed `padStart`. Identifiers, configured values and preset labels are not
* quantities at all and are exempt by the header rule, so the scan never sees
* them.
*/
test("no source file outside format.ts calls a number-formatting API", () => {
const sources = import.meta.glob<string>("../**/*.{ts,tsx}", { query: "?raw", import: "default", eager: true });
const offenders: string[] = [];
for (const [path, text] of Object.entries(sources)) {
if (/\.test\.tsx?$/.test(path) || /(^|\/)format\.ts$/.test(path)) continue;
for (const pattern of [/\.toFixed\(/, /\.toLocaleString\(/, /Intl\.NumberFormat/]) {
if (pattern.test(text)) offenders.push(`${path}: ${pattern.source}`);
}
if (/\.padStart\(/.test(text) && !path.endsWith("/activity/datetime.ts")) offenders.push(`${path}: padStart`);
}
expect(Object.keys(sources).length).toBeGreaterThan(50);
expect(offenders).toEqual([]);
});
+80 -9
View File
@@ -1,3 +1,23 @@
/**
* Every number the reader sees, formatted in one place (milestone 39).
*
* The rules are fixed so that two surfaces never spell one quantity two ways:
* counts are grouped, percentages always carry two decimals, rates one, and a
* duration is its two largest nonzero units with no zero padding. A call site
* that formats inline is a defect; the sweep test in `format.test.ts` and the
* review both hunt for one.
*
* What the contract covers is a measured quantity the reader compares: a count,
* a share, a rate, a duration, a size in bytes, a time. Three kinds of number
* are not quantities and render as written. An identifier or a protocol code —
* a group or source id, an RCODE, a QCLASS, an unknown QTYPE's number — names a
* thing rather than measuring one, though a row's ordinal is a count. A
* configured value shown next to or inside the input that edits it — a TTL of
* 3600, a cache size of 10000, a priority — must read back exactly as the
* operator typed it. And the fixed label of a preset in a menu — "Past 24
* hours", "5 minutes" — is copy, not a measurement.
*/
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
return new Intl.DateTimeFormat(locale, {
@@ -18,6 +38,49 @@ export function formatClock(unixSeconds: number, locale?: string, timeZone?: str
);
}
/**
* The label on a chart's time axis: the day for buckets a day or wider, the
* time of day otherwise.
*/
export function formatBucketTime(unixSeconds: number, bucketSeconds: number): string {
const date = new Date(unixSeconds * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}
const grouped = new Intl.NumberFormat("en-US");
/** A count, thousands-grouped: 18432 → "18,432". */
export function formatCount(count: number): string {
return grouped.format(count);
}
/**
* Round half up at `decimals` places. `toFixed` rounds the binary value, so
* 15.435 (stored just under) would print 15.43; the nudge lifts an exact half
* over the edge without moving anything else.
*/
function roundHalfUp(value: number, decimals: number): string {
const scale = 10 ** decimals;
return (Math.round(value * scale + 1e-9) / scale).toFixed(decimals);
}
/**
* A share as a percentage, always two decimals: 0.1544 → "15.44%", 0 →
* "0.00%", 1 → "100.00%". The caller decides what a share of nothing means; a
* total of zero is not this function's to guess.
*/
export function formatPercent(fraction: number): string {
return `${roundHalfUp(fraction * 100, 2)}%`;
}
/** A rate, one decimal: 12.84 → "12.8", 5 → "5.0". */
export function formatRate(value: number): string {
return roundHalfUp(value, 1);
}
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
export function formatBytes(bytes: number): string {
@@ -29,28 +92,36 @@ export function formatBytes(bytes: number): string {
value /= 1024;
if (value < 1024) break;
}
return `${value.toFixed(1)} ${unit}`;
return `${roundHalfUp(value, 1)} ${unit}`;
}
const AGE_UNITS = [
const DURATION_UNITS = [
{ seconds: 86400, suffix: "d" },
{ seconds: 3600, suffix: "h" },
{ seconds: 60, suffix: "m" },
{ seconds: 1, suffix: "s" },
] as const;
/**
* Seconds of elapsed time → a coarse "3h". Truncating and single-unit on
* purpose, for a span the caller labels itself, as in "active for 3h". A
* negative span reads "0s": clock skew is not a duration.
* Seconds of elapsed time → its two largest nonzero units, unpadded: "6d 4h",
* "4h 12m", "12m 5s", "6d 5s", "45s". Two units, because "6d" alone hides
* four hours and "6d 4h 12m 5s" is a stopwatch; nonzero, because "6d 0h" says
* nothing "6d 5s" does not. Anything under a second, a negative span included
* — clock skew is not a duration — reads "<1s".
*/
export function formatDuration(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix}`;
let rest = Math.floor(seconds);
if (rest < 1) return "<1s";
const parts: string[] = [];
for (const unit of DURATION_UNITS) {
const amount = Math.floor(rest / unit.seconds);
rest -= amount * unit.seconds;
if (amount > 0) parts.push(`${amount}${unit.suffix}`);
}
return `${Math.max(0, Math.floor(seconds))}s`;
return parts.slice(0, 2).join(" ");
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`;
return `${formatRate(micros / 1000)} ms`;
}
+5 -5
View File
@@ -21,7 +21,7 @@ import type {
export const queryKeys = {
health: ["health"] as const,
version: ["version"] as const,
overview: (period: Period) => ["overview", period] as const,
overview: (period: Period, client: string | undefined) => ["overview", period, client ?? ""] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
@@ -48,12 +48,12 @@ export const healthQuery = () =>
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, refetchInterval: 60_000 });
export const overviewQuery = (period: Period = "24h") =>
export const overviewQuery = (period: Period = "24h", client?: string) =>
queryOptions({
queryKey: queryKeys.overview(period),
queryFn: () => api.getOverview(period),
queryKey: queryKeys.overview(period, client),
queryFn: () => api.getOverview(period, client),
refetchInterval: 30_000,
});