Files
nxdns/admin/src/features/overview/chartKit.tsx
T
mokhtar 85b8be50a0
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
admin: overview redesign, device scope, one formatting contract (milestone 39)
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
2026-09-07 23:11:50 +02:00

628 lines
20 KiB
TypeScript

/**
* 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
* app wants, so every wrapper here turns those off and dresses the result in
* StyleX tokens instead.
*/
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, 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, 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;
/** How many pixels one x-axis label needs to itself before the labels thin out. */
const MIN_X_LABEL_PX = 90;
/** The gap between two touching columns, in pixels of the drawn plot. */
const BAR_GAP_PX = 2;
const Y_TICK_COUNT = 5;
export interface Plot {
x: number;
y: number;
width: number;
height: number;
/** The y of the baseline: the bottom edge of the plot. */
bottom: number;
}
/**
* 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: left,
y: MARGIN.top,
width: Math.max(0, width - left - MARGIN.right),
height,
bottom: MARGIN.top + height,
};
}
/**
* The container's width, measured on mount and on every resize.
*
* `useLayoutEffect` rather than `useEffect`, because the difference is visible:
* a layout effect measures and re-renders before the browser paints, so the
* first painted frame is already the real width. Under `useEffect` the measured
* width lands one painted frame later, and the reader sees a chart drawn at
* `FALLBACK_WIDTH` snap to its container.
*/
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (el === null) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width > 0 ? width : FALLBACK_WIDTH];
}
/**
* The value axis. `nice: true` rounds the domain outward, so the `max` passed
* here is the data's own maximum and not a pre-rounded one.
*/
export function valueScale(max: number, range: [number, number]) {
return scaleLinear<number>({ domain: [0, max], range, nice: true });
}
export type ValueScale = ReturnType<typeof valueScale>;
/**
* 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).filter(Number.isInteger);
}
/**
* How wide the gap between two columns is, as a fraction of one column's slot.
* A constant would make the gap grow with the column: 30 daily columns and 60
* minute columns are drawn at the same pixel gap, so the fraction has to be
* computed from how many columns share the plot.
*/
export function bandPaddingInner(bucketCount: number, plotWidth: number): number {
if (bucketCount === 0 || plotWidth <= 0) return 0;
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
}
/** 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,
range: [plot.x, plot.x + plot.width],
paddingInner: bandPaddingInner(values.length, plot.width),
});
}
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
* thin out rather than overprint each other.
*/
export function labelTickValues(values: number[], plotWidth: number): number[] {
if (values.length === 0 || plotWidth <= 0) return values.slice(0, 1);
const step = Math.max(1, Math.ceil((values.length * MIN_X_LABEL_PX) / plotWidth));
return values.filter((_, i) => i % step === 0);
}
/** 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;
}
/**
* 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 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({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
position: "relative",
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
tooltip: {
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
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,
},
tooltipList: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
marginTop: "0.25rem",
},
tooltipRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
},
tooltipTerm: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
color: colors.textMuted,
},
swatch: {
display: "inline-block",
width: "0.5rem",
height: "0.5rem",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
});
/** Both charts' "nothing here" state, which is also where the ref to measure lives. */
export function EmptyChart({ containerRef }: { containerRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
}
export function ChartRoot({
containerRef,
children,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode;
}) {
return (
<div ref={containerRef} {...stylex.props(styles.root)}>
{children}
</div>
);
}
/**
* visx's `Ticks` derives a label's `y` from a guessed font size, which is wrong
* here because the size comes from a stylesheet it cannot read. The label is
* therefore placed by the axis group's own transform plus the `dy` the caller
* passes, and the guessed `y` is dropped.
*/
function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
return (
<text
x={x}
y={0}
dx={dx}
dy={dy}
textAnchor={textAnchor}
dominantBaseline={dominantBaseline}
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{formattedValue}
</text>
);
}
/** As `TickLabel`, but keeping the scaled tick position the vertical axis puts in `y`. */
function ValueTickLabel({ x, y, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
return (
<text
x={x}
y={y}
dx={dx}
dy={dy}
textAnchor={textAnchor}
dominantBaseline={dominantBaseline}
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{formattedValue}
</text>
);
}
/**
* The grid and the two axes. The grid lines and the value labels are computed
* from one tick array, so every drawn line has a number beside it and no number
* floats without one.
*/
export function ChartFrame({
plot,
yScale,
yTicks,
xScale,
xTickValues,
bucketSeconds,
}: {
plot: Plot;
yScale: ValueScale;
yTicks: number[];
xScale: TimeScale;
xTickValues: number[];
bucketSeconds: number;
}) {
return (
<>
<GridRows scale={yScale} tickValues={yTicks} left={plot.x} width={plot.width} stroke={colors.border} />
<AxisLeft
scale={yScale}
left={plot.x}
tickValues={yTicks}
numTicks={Y_TICK_COUNT}
hideAxisLine
hideTicks
tickLength={0}
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" }}
tickComponent={ValueTickLabel}
/>
<AxisBottom
scale={xScale}
top={plot.bottom}
tickValues={xTickValues}
hideTicks
tickLength={0}
stroke={colors.borderStrong}
tickFormat={(value) => formatBucketTime(Number(value), bucketSeconds)}
tickLabelProps={{ dy: "16px", textAnchor: "middle" }}
tickComponent={TickLabel}
/>
</>
);
}
/**
* 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,
y,
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 + 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 or a low point still
* selects it.
*
* 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 HitBands({
plot,
centers,
onEnter,
}: {
plot: Plot;
/** The x of each bucket's mark, in bucket order. */
centers: number[];
onEnter: (index: number) => void;
}) {
const right = plot.x + plot.width;
return (
<>
{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={index}
x={x0}
y={plot.y}
width={Math.max(0, x1 - x0)}
height={plot.height}
fill="transparent"
onMouseEnter={() => onEnter(index)}
/>
);
})}
</>
);
}
/** 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 charts, a
* slice in the donuts.
*
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
* position in state decouples them from the data. The window refreshes every
* half minute and the container can be resized under an open tooltip, and either
* one leaves a stored copy describing an item that has moved or changed. Only
* the index is kept, so the caller reads the values and the coordinates out of
* the render it is currently drawing.
*
* `windowKey` is what makes a changing set safe: when the items themselves
* change, index `i` no longer names the one the pointer was over, so the
* selection made against the old key is dropped and the tooltip goes away.
*/
export function useActiveIndex(windowKey: string) {
const [selection, setSelection] = useState<{ index: number; key: string } | null>(null);
// A selection belongs to the window it was made in. Deleting it as soon as the
// key moves on is what stops a returning key — a resize back to a previous
// width, a poll that restores a bucket count — from reviving a hover the
// pointer never made.
if (selection !== null && selection.key !== windowKey) setSelection(null);
return {
index: selection !== null && selection.key === windowKey ? selection.index : null,
show: (index: number) => setSelection({ index, key: windowKey }),
clear: () => setSelection(null),
};
}
export interface TooltipRow {
key: string;
label: string;
/** Omitted for a row that names no drawn shape, such as a bucket's total. */
color?: string;
value: string;
}
/** What a tooltip says, whichever chart opened it: a heading and its rows. */
export interface TooltipContent {
title: string;
rows: TooltipRow[];
}
/**
* `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. What it keeps is the
* positioning; the box the reader sees is the element inside it.
*/
const NO_INLINE_STYLE = {};
/** 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.
// The index alone is not enough: a refresh can widen the same item's text past
// 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={beside ? BESIDE_OFFSET : CORNER_OFFSET}
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
style={NO_INLINE_STYLE}
className={stylex.props(styles.tooltipShell).className}
>
<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>
);
}