Files
nxdns/admin/src/features/overview/ClientChart.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

224 lines
7.5 KiB
TypeScript

/**
* Client activity over the same window as the query-volume chart: one stacked
* series per named client, plus everything outside the top eight as "Other".
*
* The x-axis is derived from this response's own `since` and `bucket_seconds`,
* which the API aligns with the timeseries buckets, so the two charts stack
* directly above one another and a spike in one is at the same horizontal
* position in the other. Colour goes by rank (`seriesColors.ts`): the busiest
* client wears the first palette hue, and the legend names every band.
*/
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatCount, formatTime } from "@/lib/format";
import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import {
CHART_HEIGHT,
ChartFrame,
ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
HitBands,
STACK_BLEED,
StackSegment,
bandScale,
labelTickValues,
plotArea,
slotCenter,
useActiveIndex,
useMeasuredWidth,
valueScale,
valueTicks,
type TooltipContent,
} from "./chartKit";
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
const TOOLTIP_CLIENTS = 4;
interface Series {
key: string;
label: string;
color: string;
buckets: number[];
}
/**
* "Other" last, so it sits at the top of every column rather than under a client.
* It is always a series, even at zero across the window (ui-visual-redesign.md:
* eight named clients plus Other): a reader comparing two scopes sees the same
* legend in both, and a zero says the named clients were the whole story.
*/
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
const named = data.clients.map((client, rank) => ({
key: clientKey(client.client),
// The name if the client is registered under one, the address otherwise —
// the same precedence and the same lookup the query tables use.
label: clientLabel(client.client, names)?.text ?? client.client,
color: clientSeriesColor(rank),
buckets: client.buckets,
}));
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
}
/**
* The slice of the Overview body this chart draws. Declared here rather than
* taken whole, so what the chart reads is stated where it is read.
*/
export interface ClientChartData {
since: number;
bucket_seconds: number;
clients: OverviewClientSeries[];
other: number[];
}
/** One column: the timestamp plus one entry per series, keyed by the series key. */
type Column = { ts: number } & Record<string, number>;
export default function ClientChart({ data }: { data: ClientChartData }) {
const [containerRef, width] = useMeasuredWidth();
// A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it.
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.other.length}:${width}`);
const names = useClientNames();
const series = seriesOf(data, names);
const bucketCount = data.other.length;
const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => {
const column: Column = { ts: data.since + i * data.bucket_seconds };
for (const one of series) column[one.key] = one.buckets[i] ?? 0;
return column;
});
if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) {
return <EmptyChart containerRef={containerRef} />;
}
const timestamps = columns.map((column) => column.ts);
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
const plot = plotArea(width, Math.max(...totals));
const xScale = bandScale(timestamps, plot);
// Every series here is a disjoint part of the whole rather than a highlighted
// subset of a separately reported total, so the tallest column's own sum is
// the scale.
const yScale = valueScale(Math.max(...totals), [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale);
const colorOf = new Map(series.map((one) => [one.key, one.color]));
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
/**
* The bucket's busiest clients, then Other, then the total — eight named
* rows would be a table, and the reader pointing at a spike wants to know
* who made it. Other is always there, at zero when the named clients were
* the whole bucket, so the rows read the same from bucket to bucket.
*/
function tooltipOf(index: number): TooltipContent {
const column = columns[index];
const named = series
.filter((one) => one.key !== OTHER_KEY && column[one.key] > 0)
.sort((a, b) => column[b.key] - column[a.key])
.slice(0, TOOLTIP_CLIENTS);
const other = series.find((one) => one.key === OTHER_KEY);
const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({
key: one.key,
label: one.label,
color: one.color,
value: formatCount(column[one.key]),
}));
return {
title: formatTime(column.ts),
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
};
}
return (
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
onMouseLeave={hovered.clear}
>
<ChartFrame
plot={plot}
yScale={yScale}
yTicks={yTicks}
xScale={xScale}
xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds}
/>
<BarStack<Column, string>
data={columns}
keys={series.map((one) => one.key)}
x={(column) => column.ts}
xScale={xScale}
yScale={yScale}
color={(key) => colorOf.get(key) ?? seriesColor(key)}
>
{(stacks) =>
columns.map((column, index) => (
<Group
key={column.ts}
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
>
{stacks.map((stack) => {
const bar = stack.bars[index];
const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED;
return (
<StackSegment
key={stack.key}
x={bar.x}
y={bar.y}
width={bar.width}
height={bar.height}
fill={bar.color}
bleed={restsOnAnother ? STACK_BLEED : 0}
/>
);
})}
</Group>
))
}
</BarStack>
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg>
{hovered.index !== null && (
<ChartTooltip index={hovered.index} content={tooltipOf(hovered.index)} left={centers[hovered.index]} />
)}
<ChartLegend entries={series} />
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per client per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
{series.map((one) => (
<th key={one.key} scope="col">
{one.label}
</th>
))}
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
{series.map((one) => (
<td key={one.key}>{formatCount(column[one.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</ChartRoot>
);
}