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
+210 -84
View File
@@ -1,7 +1,7 @@
/**
* The plumbing the two bar charts on Overview share: the width measurement, the
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
* target and the tooltip.
* The plumbing the charts on Overview share: the width measurement, the scales,
* the axis and grid chrome, the smoothed path, the stacked segment, the
* per-bucket hit target and the tooltip.
*
* Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
* marks, an axis line on both axes and Arial 10px in #222, none of which this
@@ -13,14 +13,20 @@ import { useLayoutEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear } from "@visx/scale";
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip";
import { formatBucketTime, formatCount } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors, layers } from "@/ui/tokens.stylex";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
export const CHART_HEIGHT = 240;
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
/** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */
export const TICK_GLYPH_PX = 6;
/** The tick label's gap to the axis (`dx` below) plus a little air. */
export const TICK_LABEL_GAP_PX = 10;
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
const FALLBACK_WIDTH = 640;
@@ -41,12 +47,20 @@ export interface Plot {
bottom: number;
}
export function plotArea(width: number): Plot {
/**
* The plot inside the axes. The left margin grows with the widest y label the
* chart can show: counts are thousands-grouped everywhere, ticks included, so
* "120,000" needs the room "120" does not. The tick above `maxValue` can add a
* digit or a comma, so the width is measured on a quarter more.
*/
export function plotArea(width: number, maxValue = 0): Plot {
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
const label = formatCount(Math.ceil(maxValue * 1.25));
const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
return {
x: MARGIN.left,
x: left,
y: MARGIN.top,
width: Math.max(0, width - MARGIN.left - MARGIN.right),
width: Math.max(0, width - left - MARGIN.right),
height,
bottom: MARGIN.top + height,
};
@@ -86,9 +100,13 @@ export function valueScale(max: number, range: [number, number]) {
export type ValueScale = ReturnType<typeof valueScale>;
/** The tick values the grid and the value axis both draw. */
/**
* The tick values the grid and the value axis both draw. A query count is a
* whole number, so d3's fractional ticks — 0.5 of a query at a peak of 1 — are
* dropped rather than labelled.
*/
export function valueTicks(scale: ValueScale): number[] {
return scale.ticks(Y_TICK_COUNT);
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
}
/**
@@ -102,7 +120,7 @@ export function bandPaddingInner(bucketCount: number, plotWidth: number): number
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
}
/** The time axis: one band per bucket, in the order the buckets were given. */
/** The time axis of a bar chart: one band per bucket, in the order the buckets were given. */
export function bandScale(values: number[], plot: Plot) {
return scaleBand<number>({
domain: values,
@@ -113,6 +131,19 @@ export function bandScale(values: number[], plot: Plot) {
export type BandScale = ReturnType<typeof bandScale>;
/**
* The time axis of the area chart: the first bucket on the plot's left edge and
* the last on its right, so the curve never runs past its own data and the
* reader can see the last point.
*/
export function pointScale(values: number[], plot: Plot) {
return scalePoint<number>({ domain: values, range: [plot.x, plot.x + plot.width] });
}
export type PointScale = ReturnType<typeof pointScale>;
export type TimeScale = BandScale | PointScale;
/**
* The subset of bucket timestamps that get an x-axis label. A 30-day window is
* 30 columns and a 1-hour window is 60, so at narrow widths the labels have to
@@ -124,14 +155,43 @@ export function labelTickValues(values: number[], plotWidth: number): number[] {
return values.filter((_, i) => i % step === 0);
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
/** One decimal of path precision: enough for a pixel, short enough to keep the DOM small. */
function coordinate(value: number): number {
return Math.round(value * 10) / 10;
}
export function formatBucketTime(ts: number, bucketSeconds: number): string {
const date = new Date(ts * 1000);
if (bucketSeconds >= 86_400) {
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
/**
* A curve through every point, as cubic Béziers derived from a Catmull-Rom
* spline. The control points' y is clamped to the plot, because a spline
* through a spike overshoots and would otherwise draw the curve dipping below
* the baseline beside a zero bucket. The curve passes exactly through each
* point, so the hover dots sit at the coordinates the path was built from.
*/
export function smoothPath(points: [number, number][], top: number, bottom: number): string {
if (points.length === 0) return "";
const clamp = (y: number) => Math.max(top, Math.min(bottom, y));
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
let d = `M${coordinate(points[0][0])},${coordinate(points[0][1])}`;
for (let i = 0; i < points.length - 1; i += 1) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
const c1y = clamp(p1[1] + (p2[1] - p0[1]) / 6);
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
const c2y = clamp(p2[1] - (p3[1] - p1[1]) / 6);
d += ` C${coordinate(c1x)},${coordinate(c1y)} ${coordinate(c2x)},${coordinate(c2y)} ${coordinate(p2[0])},${coordinate(p2[1])}`;
}
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
return d;
}
/** The same curve closed down to the baseline, for the fill under the line. */
export function areaPath(line: string, points: [number, number][], plot: Plot): string {
if (points.length === 0) return "";
const first = coordinate(points[0][0]);
const last = coordinate(points[points.length - 1][0]);
return `${line} L${last},${plot.bottom} L${first},${plot.bottom} Z`;
}
const styles = stylex.create({
@@ -140,7 +200,7 @@ const styles = stylex.create({
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
@@ -155,15 +215,8 @@ const styles = stylex.create({
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
tooltip: {
pointerEvents: "none",
position: "absolute",
zIndex: layers.tooltip,
borderRadius: "0.25rem",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -174,6 +227,12 @@ const styles = stylex.create({
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */
tooltipShell: {
pointerEvents: "none",
position: "absolute",
zIndex: layers.tooltip,
},
tooltipTitle: {
fontWeight: 500,
},
@@ -201,7 +260,7 @@ const styles = stylex.create({
height: "0.5rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
@@ -283,7 +342,7 @@ export function ChartFrame({
plot: Plot;
yScale: ValueScale;
yTicks: number[];
xScale: BandScale;
xScale: TimeScale;
xTickValues: number[];
bucketSeconds: number;
}) {
@@ -298,7 +357,7 @@ export function ChartFrame({
hideAxisLine
hideTicks
tickLength={0}
tickFormat={(value) => compact.format(Number(value))}
tickFormat={(value) => formatCount(Number(value))}
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
// with the middle baseline this app centres its value labels on.
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
@@ -320,9 +379,9 @@ export function ChartFrame({
}
/**
* One segment of a stacked column. The separator is drawn only once the column
* is wide enough for two neighbouring segments to read as two shapes; below
* that it would be most of the bar.
* One segment of a stacked column. `bleed` extends the segment down into the
* one drawn before it, so antialiasing cannot open a seam of ground between
* two touching fills: the blank lines the owner saw across the client chart.
*/
export function StackSegment({
x,
@@ -330,59 +389,54 @@ export function StackSegment({
width,
height,
fill,
bleed = 0,
}: {
x: number;
y: number;
width: number;
height: number;
fill: string;
bleed?: number;
}) {
if (height <= 0) return null;
return (
<rect
x={x}
y={y}
width={width}
height={height}
fill={fill}
strokeWidth={width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
return <rect x={x} y={y} width={width} height={height + bleed} fill={fill} />;
}
/** The amount one upper segment overlaps the one under it, in plot units. */
export const STACK_BLEED = 0.5;
/**
* The transparent hit targets: one per bucket, spanning the whole plot height so
* that pointing at the empty space above a short column still selects it.
* that pointing at the empty space above a short column or a low point still
* selects it.
*
* A slot is a whole band step, gap included, so that no pixel between two
* columns belongs to neither. The last slot is clipped to the plot's right edge:
* the band scale spends the trailing gap on nothing, and a full-step rect there
* would reach into the right margin.
* The bands tile the plot edge to edge with their boundaries midway between
* neighbouring marks, so no pixel belongs to no bucket and landing in a band is
* already "snap to the nearest bucket". The same tiling serves band centres
* (bars) and points spread edge to edge (the area chart).
*/
export function BucketOverlay({
export function HitBands({
plot,
values,
xScale,
centers,
onEnter,
}: {
plot: Plot;
values: number[];
xScale: BandScale;
/** The x of each bucket's mark, in bucket order. */
centers: number[];
onEnter: (index: number) => void;
}) {
const step = xScale.step();
const right = plot.x + plot.width;
return (
<>
{values.map((value, index) => {
const x = xScale(value) ?? plot.x;
{centers.map((cx, index) => {
const x0 = index === 0 ? plot.x : (centers[index - 1] + cx) / 2;
const x1 = index === centers.length - 1 ? right : (cx + centers[index + 1]) / 2;
return (
<rect
key={value}
x={x}
key={index}
x={x0}
y={plot.y}
width={Math.max(0, Math.min(step, right - x))}
width={Math.max(0, x1 - x0)}
height={plot.height}
fill="transparent"
onMouseEnter={() => onEnter(index)}
@@ -393,13 +447,13 @@ export function BucketOverlay({
);
}
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
/** The x a bar's tooltip points at: the centre of its slot, not of its narrower bar. */
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
return (xScale(value) ?? plot.x) + xScale.step() / 2;
}
/**
* Which item the pointer is on, and nothing else — a bucket in the bar charts, a
* Which item the pointer is on, and nothing else — a bucket in the charts, a
* slice in the donuts.
*
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
@@ -444,24 +498,41 @@ export interface TooltipContent {
/**
* `TooltipWithBounds` positions itself with an inline transform and drops it
* when `unstyled` is set, so the default look is replaced by handing it an empty
* style object rather than by turning styling off.
* style object rather than by turning styling off. What it keeps is the
* positioning; the box the reader sees is the element inside it.
*/
const NO_INLINE_STYLE = {};
/** The gap the tooltip keeps from its anchor point. */
const TOOLTIP_OFFSET = 8;
/** The gap a tooltip hung from a chart's corner keeps from its anchor point. */
const CORNER_OFFSET = 8;
/**
* The gap a tooltip beside a point keeps from it: enough that the box never
* covers the dot or the slot of data the reader is looking at.
*/
const BESIDE_OFFSET = 16;
export function ChartTooltip({
content,
index,
left,
top = 0,
beside = false,
}: {
content: TooltipContent;
/** Which item the tooltip names; part of what forces a fresh measurement. */
index: number;
left: number;
top?: number;
/**
* Beside the anchor at its own height rather than hung from the chart's top:
* the area chart's placement, where `top` is the point's y. The centring is a
* negative half-height `offsetTop`, not a CSS transform, so that the flip
* `TooltipWithBounds` computes from its own rect sees where the box really
* lands. It flips the box to the anchor's left when it would run past the
* right edge, and above the anchor when it would run past the bottom.
*/
beside?: boolean;
}) {
// `withBoundingRects` measures once, in `componentDidMount`, and never again,
// so every content change needs its own mount to be measured at its own size.
@@ -469,33 +540,88 @@ export function ChartTooltip({
// a digit boundary, or resolve a client's name, and the stale width would place
// it wrongly at the right edge.
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
const box = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
// The centring offset is half the box's own height, so it cannot be known
// before the box exists. `measureKey` remounts the whole tooltip per content
// change, so one measurement per mount covers every size the box takes.
useLayoutEffect(() => {
if (box.current) setHeight(box.current.offsetHeight);
}, []);
return (
<TooltipWithBounds
key={measureKey}
left={left}
top={top}
offsetLeft={TOOLTIP_OFFSET}
offsetTop={TOOLTIP_OFFSET}
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
style={NO_INLINE_STYLE}
className={stylex.props(styles.tooltip).className}
className={stylex.props(styles.tooltipShell).className}
>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}>
{content.rows.map((row) => (
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
{row.color !== undefined && (
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
/>
)}
{row.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
</div>
))}
</dl>
<div ref={box} {...stylex.props(styles.tooltip)}>
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
<dl {...stylex.props(styles.tooltipList)}>
{content.rows.map((row) => (
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
{row.color !== undefined && (
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
/>
)}
{row.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
</div>
))}
</dl>
</div>
</TooltipWithBounds>
);
}
const legendStyles = stylex.create({
legend: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
item: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
swatch: {
display: "inline-block",
width: "0.625rem",
height: "0.625rem",
borderRadius: "0.125rem",
},
swatchColor: (color: string) => ({ backgroundColor: color }),
});
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
export function ChartLegend({ entries }: { entries: { key: string; label: string; color: string }[] }) {
return (
<ul {...stylex.props(legendStyles.legend)}>
{entries.map((entry) => (
<li key={entry.key} {...stylex.props(legendStyles.item)}>
<span
aria-hidden="true"
{...stylex.props(legendStyles.swatch, legendStyles.swatchColor(entry.color))}
/>
{entry.label}
</li>
))}
</ul>
);
}