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
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:
+80
-9
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user