+
+ );
+}
diff --git a/admin/src/features/overview/ClientChart.test.tsx b/admin/src/features/overview/ClientChart.test.tsx
index 0c9f2b6..beeec88 100644
--- a/admin/src/features/overview/ClientChart.test.tsx
+++ b/admin/src/features/overview/ClientChart.test.tsx
@@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart";
-import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
+import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
const SINCE = 1_700_000_000;
const BUCKET = 1800;
@@ -89,13 +89,41 @@ test("the value scale covers the tallest column's total, not its largest series"
expect(labels[labels.length - 1]).toBe("35");
});
-test("the series are drawn in the colour of the client's address, and Other in its own", () => {
- const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));
+test("the series are coloured by rank, and Other in its own gray", () => {
+ const { container } = render(
+ clients(
+ [
+ { client: "192.0.2.30", buckets: [10] },
+ { client: "192.0.2.31", buckets: [4] },
+ ],
+ [5],
+ ),
+ );
const fills = Array.from(container.querySelectorAll("rect"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
- expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
+ expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
+});
+
+/**
+ * The blank lines the owner saw across the chart: two touching fills with an
+ * antialiased seam of ground between them. Every segment that rests on another
+ * reaches half a unit down into it; the bottom segment stops at the baseline.
+ */
+test("a segment resting on another overlaps it by half a unit, so no seam can open", () => {
+ const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [10]));
+
+ const [lower, upper] = Array.from(container.querySelectorAll("rect")).filter(
+ (rect) => rect.getAttribute("fill") !== "transparent",
+ );
+ const lowerTop = Number(lower.getAttribute("y"));
+ const upperBottom = Number(upper.getAttribute("y")) + Number(upper.getAttribute("height"));
+ expect(upperBottom - lowerTop).toBeCloseTo(0.5, 6);
+ // The bottom segment ends exactly on the baseline (plot bottom is 240 - 22).
+ expect(Number(lower.getAttribute("y")) + Number(lower.getAttribute("height"))).toBeCloseTo(218, 6);
+ // No segment strokes itself any more: the bleed does the separating work.
+ expect(lower.getAttribute("stroke")).toBeNull();
});
test("a window with no queries says so instead of drawing an empty grid", () => {
@@ -151,7 +179,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
- ).toEqual(["35", "10", "20", "5"]);
+ ).toEqual(["20", "10", "5", "35"]);
rerender(
clients(
@@ -165,7 +193,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
- ).toEqual(["39", "11", "22", "6"]);
+ ).toEqual(["22", "11", "6", "39"]);
});
/**
@@ -212,28 +240,51 @@ test("each bucket gets its own tooltip mount, so each is measured for itself", (
expect(container.querySelector(".visx-tooltip")).not.toBe(first);
});
-test("pointing at a bucket names its total and every series, and dims the rest", () => {
+test("pointing at a bucket names its busiest clients, then Other, then the total, and dims the rest", () => {
const { container } = render(TWO_BUCKETS);
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
+ // Busiest first in this bucket, whatever the legend's order.
expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
- "Queries",
- "192.0.2.30",
"192.0.2.31",
+ "192.0.2.30",
"Other",
+ "All clients",
]);
- expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
+ expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["20", "10", "5", "35"]);
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
- expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
+ expect(swatches[0]).toContain(clientSeriesColor(1));
+ expect(swatches[1]).toContain(clientSeriesColor(0));
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
});
+/**
+ * Eight named rows would be a table. The reader pointing at a spike wants to
+ * know who made it, so the tooltip stops at the bucket's four busiest clients
+ * and leaves the legend and the hidden table to name the rest.
+ */
+test("a bucket's tooltip lists at most four named clients, the quiet ones dropped", () => {
+ const named = Array.from({ length: 6 }, (_, i) => ({ client: `192.0.2.${40 + i}`, buckets: [i + 1, 0] }));
+ const { container } = render(clients(named, [0, 0]));
+
+ fireEvent.mouseOver(overlayRects(container)[0]);
+ const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
+ expect(terms.map((term) => term.textContent)).toEqual([
+ "192.0.2.45",
+ "192.0.2.44",
+ "192.0.2.43",
+ "192.0.2.42",
+ "All clients",
+ ]);
+ expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(7);
+});
+
test("leaving the chart takes the tooltip and the dimming with it", () => {
const { container } = render(TWO_BUCKETS);
@@ -264,7 +315,12 @@ test("a window where Other counted nothing drops it from every surface", () => {
fireEvent.mouseOver(overlayRects(container)[0]);
const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
- expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]);
+ expect(terms.map((term) => term.textContent)).toEqual(["192.0.2.30", "All clients"]);
+});
+
+test("the hidden table groups its counts like every other figure on the page", () => {
+ render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
+ expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
});
test("one query outside the named clients is enough to keep Other", () => {
diff --git a/admin/src/features/overview/ClientChart.tsx b/admin/src/features/overview/ClientChart.tsx
index 2418279..bbdf6f5 100644
--- a/admin/src/features/overview/ClientChart.tsx
+++ b/admin/src/features/overview/ClientChart.tsx
@@ -3,27 +3,28 @@
* 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 endpoint's 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 keys on the client string, so a client that
- * changes rank between polls keeps its colour.
+ * 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 { formatTime } from "@/lib/format";
+import { formatCount, formatTime } from "@/lib/format";
import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
-import { colors } from "@/ui/tokens.stylex";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import {
- BucketOverlay,
CHART_HEIGHT,
ChartFrame,
+ ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
+ HitBands,
+ STACK_BLEED,
StackSegment,
bandScale,
labelTickValues,
@@ -35,36 +36,10 @@ import {
valueTicks,
type TooltipContent,
} from "./chartKit";
-import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
+import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
-const styles = stylex.create({
- legend: {
- marginTop: "0.5rem",
- display: "flex",
- flexWrap: "wrap",
- columnGap: "1rem",
- rowGap: "0.25rem",
- listStyleType: "none",
- padding: 0,
- margin: 0,
- fontSize: "0.75rem",
- lineHeight: "1rem",
- color: colors.textSecondary,
- },
- legendItem: {
- display: "flex",
- alignItems: "center",
- gap: "0.375rem",
- },
- swatch: {
- display: "inline-block",
- width: "0.625rem",
- height: "0.625rem",
- borderRadius: "0.125rem",
- },
- /** Dynamic: the swatch takes the colour the bars are drawn in. */
- swatchColor: (color: string) => ({ backgroundColor: color }),
-});
+/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
+const TOOLTIP_CLIENTS = 4;
interface Series {
key: string;
@@ -81,13 +56,12 @@ interface Series {
* client that went quiet is something the reader wants to see.
*/
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
- const named = data.clients.map((client) => ({
+ 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. The colour
- // keys on the address regardless, so naming a client never repaints it.
+ // the same precedence and the same lookup the query tables use.
label: clientLabel(client.client, names)?.text ?? client.client,
- color: seriesColor(clientKey(client.client)),
+ color: clientSeriesColor(rank),
buckets: client.buckets,
}));
if (data.other.every((count) => count === 0)) return named;
@@ -137,19 +111,29 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
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 the aggregate, then the total — eight
+ * named rows would be a table, and the reader pointing at a spike wants to
+ * know who made it.
+ */
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 && column[OTHER_KEY] > 0 ? [other] : [])].map((one) => ({
+ key: one.key,
+ label: one.label,
+ color: one.color,
+ value: formatCount(column[one.key]),
+ }));
return {
- title: formatTime(columns[index].ts),
- rows: [
- { key: "queries", label: "Queries", value: String(totals[index]) },
- ...series.map((one) => ({
- key: one.key,
- label: one.label,
- color: one.color,
- value: String(columns[index][one.key]),
- })),
- ],
+ title: formatTime(column.ts),
+ rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
};
}
@@ -187,6 +171,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
>
{stacks.map((stack) => {
const bar = stack.bars[index];
+ const restsOnAnother = bar.y + bar.height < plot.bottom - STACK_BLEED;
return (
);
})}
@@ -202,23 +188,12 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
))
}
-
+
{hovered.index !== null && (
-
+
)}
-
- {series.map((one) => (
-
-
- {one.label}
-
- ))}
-
+
Queries per client per time bucket
@@ -237,7 +212,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
{formatTime(column.ts)}
{series.map((one) => (
-
{column[one.key]}
+
{formatCount(column[one.key])}
))}
))}
diff --git a/admin/src/features/overview/Donut.test.tsx b/admin/src/features/overview/Donut.test.tsx
index 634e08f..b93cdc8 100644
--- a/admin/src/features/overview/Donut.test.tsx
+++ b/admin/src/features/overview/Donut.test.tsx
@@ -57,11 +57,11 @@ test("the ring starts at twelve o'clock and runs clockwise in the order given",
const paths = ring(container);
// The small slice is drawn first because it was given first.
expect(paths[0].getAttribute("fill")).toBe("#112233");
- expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
+ expect(paths[0].getAttribute("d")?.startsWith("M0,-63.5")).toBe(true);
// A quarter turn clockwise from the top is three o'clock, where the second
// slice picks up.
expect(paths[1].getAttribute("fill")).toBe("#445566");
- expect(paths[1].getAttribute("d")?.startsWith("M90,0")).toBe(true);
+ expect(paths[1].getAttribute("d")?.startsWith("M63.5,0")).toBe(true);
});
/**
@@ -76,16 +76,16 @@ test("a single entry is a closed ring, not a zero-length arc", () => {
expect(paths).toHaveLength(1);
const d = paths[0].getAttribute("d") ?? "";
expect(d.match(/A/g)).toHaveLength(4);
- // Two half arcs out at the outer radius and two back at the inner one: a
- // 180px ring 36px thick.
- expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
+ // Two half arcs out at the outer radius and two back at the inner one: the
+ // decision record's ring, 127px across and 15px thick.
+ expect(arcRadii(d)).toEqual([63.5, 63.5, 48.5, 48.5]);
});
test("shares are of the drawn total, in the legend and in the hidden table alike", () => {
draw([slice("a", 3), slice("b", 1)]);
- expect(screen.getAllByText("75.0%")).toHaveLength(2);
- expect(screen.getAllByText("25.0%")).toHaveLength(2);
+ expect(screen.getAllByText("75.00%")).toHaveLength(2);
+ expect(screen.getAllByText("25.00%")).toHaveLength(2);
});
/**
@@ -136,9 +136,9 @@ test("pointing at a slice names it and dims the rest", () => {
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe("A");
expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
- expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
+ expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.00%"]);
// The same share the legend and the hidden table already print for this slice.
- expect(screen.getAllByText("75.0%")).toHaveLength(3);
+ expect(screen.getAllByText("75.00%")).toHaveLength(3);
expect(paths.map((path) => path.getAttribute("opacity"))).toEqual(["1", "0.55"]);
expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
@@ -146,11 +146,11 @@ test("pointing at a slice names it and dims the rest", () => {
// The tooltip points at the middle of the arc, which the component computes
// from the slice values rather than from the drawn path. Slice A is three
// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
- // radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
- // the tooltip stands off by. Nothing else here would catch that arithmetic
- // drifting away from the ring the Pie actually draws.
+ // radius 56 around the box's centre at 76 — (115.6, 115.6) from the ring's
+ // top-left corner, plus the 8px the tooltip stands off by. Nothing else here
+ // would catch that arithmetic drifting away from the ring the Pie draws.
const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
- expect(tooltipBox.style.transform).toBe("translate(149px, 149px)");
+ expect(tooltipBox.style.transform).toBe("translate(124px, 124px)");
});
test("leaving the ring takes the tooltip and the dimming with it", () => {
@@ -175,14 +175,14 @@ test("a refresh keeps the hovered slice current and remeasures it", () => {
fireEvent.mouseOver(ring(container)[0]);
const first = container.querySelector(".visx-tooltip");
- expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
+ expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.00%"]);
rerender();
const second = container.querySelector(".visx-tooltip");
expect(Array.from(second?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual([
"3,000",
- "75.0%",
+ "75.00%",
]);
expect(second).not.toBe(first);
});
diff --git a/admin/src/features/overview/Donut.tsx b/admin/src/features/overview/Donut.tsx
index 219a5e7..e53da63 100644
--- a/admin/src/features/overview/Donut.tsx
+++ b/admin/src/features/overview/Donut.tsx
@@ -15,8 +15,9 @@
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { Pie } from "@visx/shape";
+import { formatCount, formatPercent } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
-import { colors } from "@/ui/tokens.stylex";
+import { colors, metrics } from "@/ui/tokens.stylex";
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";
export interface DonutSlice {
@@ -29,38 +30,25 @@ export interface DonutSlice {
color: string;
}
-const SIZE = 180;
-const THICKNESS = 36;
-const OUTER_RADIUS = SIZE / 2;
+/** The decision record's ring: a 152px box, a 15px band, the total in the hole. */
+const SIZE = 152;
+const THICKNESS = 15;
+const OUTER_RADIUS = SIZE / 2 - 12.5;
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;
-/** The gap `body` puts between the ring and the legend, in pixels: 1.25rem. */
-const BODY_GAP = 20;
-/** One `legend` row, in pixels: its 1.25rem line height. */
-const LEGEND_ROW = 20;
-
-const numberFormat = new Intl.NumberFormat();
-
-/** The width at which the page puts the two donuts side by side, and the page's
- * own grid switches on the same query. StyleX will not take it from an import,
- * so it is written out in both modules and must be changed in both. */
-const TWO_COLUMN = "@media (min-width: 1280px)";
+/** The gap `body` puts between the ring and the legend, in pixels: 1.5rem. */
+const BODY_GAP = 24;
+/** One `legend` row, in pixels: its 1.25rem line height plus the divider's padding. */
+const LEGEND_ROW = 30;
const styles = stylex.create({
- /**
- * The reserve is the ring and a legend, not the ring alone: 180 + 20 + 20 =
- * 220px. `body` wraps once the panel is narrower than the ring plus the
- * legend's 12rem floor, and below that width the filled panel is the ring, the
- * body gap and at least one legend row. Side by side the same 220px holds a
- * legend of nine rows, which is more than either breakdown draws — the API
- * caps neither, so the ring's own height is not a ceiling.
- */
+ /** The reserve is the ring and one legend row, so an empty panel stands as tall as a filled one. */
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: SIZE + BODY_GAP + LEGEND_ROW,
- borderRadius: "0.25rem",
+ borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
@@ -68,18 +56,11 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
- /**
- * Centred while the panels are stacked, left-anchored once they are side by
- * side. Stacked, the panel is as wide as the page and a ring pinned to the
- * left edge reads as a mistake; in a column it is one of a pair and lines up
- * with everything above it.
- */
body: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
- justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
- gap: "1.25rem",
+ gap: "1.5rem",
},
/** The tooltip is placed against the ring's own box, so slice coordinates can
* be used unchanged rather than measured against the whole panel. */
@@ -88,28 +69,36 @@ const styles = stylex.create({
flexShrink: 0,
lineHeight: 0,
},
- /**
- * Capped and left-anchored. Without the cap the row justifies across whatever
- * the panel is given — most of a metre of whitespace on a wide monitor — and a
- * label stops reading as belonging to the count opposite it.
- */
+ centerTotal: {
+ fill: colors.text,
+ fontSize: "1.25rem",
+ fontWeight: 650,
+ letterSpacing: "-0.01em",
+ },
+ centerUnit: {
+ fill: colors.textMuted,
+ fontSize: "0.75rem",
+ },
legend: {
flex: 1,
minWidth: "12rem",
- maxWidth: "24rem",
display: "flex",
flexDirection: "column",
- gap: "0.25rem",
listStyleType: "none",
padding: 0,
margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
+ /** Rows are divided by hairlines, not by a gap, so the list reads as one table. */
legendItem: {
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
+ paddingBlock: "0.3125rem",
+ borderTopWidth: { default: 0, ":not(:first-child)": 1 },
+ borderTopStyle: "solid",
+ borderTopColor: colors.border,
},
swatch: {
pointerEvents: "none",
@@ -133,20 +122,17 @@ const styles = stylex.create({
lineHeight: "1rem",
color: colors.textMuted,
},
- count: {
+ figures: {
+ display: "inline-flex",
+ gap: "0.375rem",
+ whiteSpace: "nowrap",
color: colors.textSecondary,
},
share: {
- minWidth: "3rem",
- textAlign: "right",
color: colors.textMuted,
},
});
-function sharePercent(share: number): string {
- return `${(share * 100).toFixed(1)}%`;
-}
-
/**
* Where a slice's tooltip points: the middle of its arc, in the ring box's own
* coordinates. This restates the `Pie` configuration below — clockwise from
@@ -157,8 +143,8 @@ function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left:
const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
return {
- left: OUTER_RADIUS + Math.sin(middle) * radius,
- top: OUTER_RADIUS - Math.cos(middle) * radius,
+ left: SIZE / 2 + Math.sin(middle) * radius,
+ top: SIZE / 2 - Math.cos(middle) * radius,
};
}
@@ -170,7 +156,7 @@ export default function Donut({
slices: DonutSlice[];
/** Names the hidden table, so a screen reader knows which breakdown it is in. */
caption: string;
- /** The column header for the counted thing, e.g. "Queries". */
+ /** The counted thing, e.g. "Queries": the table's column header and, lowercased, the word under the total. */
unit: string;
}) {
// Zero-valued entries have no arc to draw and a legend entry reading 0 is
@@ -186,8 +172,8 @@ export default function Donut({
return {
title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
rows: [
- { key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
- { key: "share", label: "Share", value: sharePercent(slice.value / total) },
+ { key: "value", label: unit, color: slice.color, value: formatCount(slice.value) },
+ { key: "share", label: "Share", value: formatPercent(slice.value / total) },
],
};
}
@@ -199,8 +185,8 @@ export default function Donut({
return (
- {/* The ring stays out of the accessibility tree even though it is now
- a pointer target: the tooltip repeats what the legend beside it
+ {/* The ring stays out of the accessibility tree even though it is a
+ pointer target: the tooltip repeats what the legend beside it
already says in text, so nothing here is the only copy. */}
{hovered.index !== null && (
@@ -269,11 +258,10 @@ export default function Donut({
{slice.secondary}
)}
-
- {numberFormat.format(slice.value)}
-
-
- {sharePercent(slice.value / total)}
+
+ {formatCount(slice.value)}
+ ·
+ {formatPercent(slice.value / total)}
))}
@@ -296,8 +284,8 @@ export default function Donut({
? slice.label
: `${slice.label} (${slice.secondary})`}
-
{slice.value}
-
{sharePercent(slice.value / total)}
+
{formatCount(slice.value)}
+
{formatPercent(slice.value / total)}
))}
diff --git a/admin/src/features/overview/OverviewFrame.tsx b/admin/src/features/overview/OverviewFrame.tsx
index f1cd274..5ddea45 100644
--- a/admin/src/features/overview/OverviewFrame.tsx
+++ b/admin/src/features/overview/OverviewFrame.tsx
@@ -1,6 +1,7 @@
/**
- * The part of Overview that does not wait for anything: the heading, the period
- * picker, and the pulsing body the page shows while the window is in flight.
+ * The part of Overview that does not wait for anything: the heading, the
+ * toolbar with the device and period selectors, and the pulsing body the page
+ * shows while the window is in flight.
*
* It lives apart from `OverviewPage` so the route's pending component can render
* the identical surface while the page chunk loads. Importing the page itself
@@ -9,18 +10,35 @@
*/
import * as stylex from "@stylexjs/stylex";
+import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
-import { Radio, RadioGroup } from "react-aria-components";
+import { clientLabel, useClientNames } from "@/features/clients/clientNames";
+import { clientsQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
+import Select, { type SelectOption } from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
-import { colors, metrics } from "@/ui/tokens.stylex";
+import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_PERIOD, PERIODS } from "./period";
+const NARROW = "@media (max-width: 800px)";
+
+const PERIOD_LABELS: Record = {
+ "1h": "Last hour",
+ "24h": "Last 24 hours",
+ "7d": "Last 7 days",
+ "30d": "Last 30 days",
+};
+
+const PERIOD_OPTIONS: SelectOption[] = PERIODS.map((period) => ({ value: period, label: PERIOD_LABELS[period] }));
+
+/** The Select's key for the whole household; a client scope is the address itself. */
+const ALL_DEVICES = "";
+
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
- gap: "1rem",
+ gap: "1.25rem",
},
headingRow: {
display: "flex",
@@ -30,60 +48,27 @@ const styles = stylex.create({
gap: "0.75rem",
},
heading: {
+ margin: 0,
fontSize: "1.5rem",
lineHeight: "2rem",
- fontWeight: 600,
+ fontWeight: 650,
+ letterSpacing: "-0.015em",
textWrap: "balance",
},
- periodGroup: {
+ /** Device on the left, period on the right; on a phone the pair takes the whole row. */
+ toolbar: {
display: "flex",
- gap: "0.25rem",
+ gap: "0.5rem",
+ flexBasis: { default: null, [NARROW]: "100%" },
+ },
+ control: {
+ minWidth: { default: "11rem", [NARROW]: 0 },
+ flex: { default: null, [NARROW]: 1 },
},
/**
- * The weight lives here rather than on the selected variant: selection may
- * change colour, but a heavier label would re-measure the row and shift every
- * option beside it.
- */
- period: {
- cursor: "pointer",
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- minHeight: metrics.hitTarget,
- minWidth: metrics.hitTarget,
- borderStyle: "none",
- borderRadius: "0.25rem",
- paddingInline: "0.625rem",
- paddingBlock: "0.25rem",
- fontSize: "0.875rem",
- lineHeight: "1.25rem",
- fontWeight: 500,
- },
- /** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
- periodFocusVisible: {
- outlineWidth: 2,
- outlineStyle: "solid",
- outlineColor: colors.focus,
- outlineOffset: 2,
- },
- /** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
- periodSelected: {
- backgroundColor: {
- default: "oklch(92% 0.004 286.32)",
- "@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
- },
- color: colors.text,
- },
- periodIdle: {
- backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
- color: colors.textSecondary,
- transitionProperty: metrics.transitionProperty,
- transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
- },
- /**
- * The height approximates the filled overview — stat tiles, a 240px chart and
- * a 180px donut with the panel chrome around them — so that the page does not
- * jump when the window lands. That is where the number comes from.
+ * The height approximates the filled overview — stat tiles, two 240px charts
+ * and the row of cards under them — so that the page does not jump when the
+ * window lands. That is where the number comes from.
*/
loading: {
minHeight: "48rem",
@@ -93,31 +78,60 @@ const styles = stylex.create({
},
});
-export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
+export interface OverviewScope {
+ period: Period;
+ client: string | undefined;
+}
+
+/**
+ * The devices the reader can scope to: the whole household first, then every
+ * registered client under its name. A scope the URL carries that the list has
+ * never seen is still offered, as its address, so the control shows the scope
+ * the page is actually under rather than silently claiming the household.
+ */
+function useDeviceOptions(client: string | undefined): SelectOption[] {
+ const clients = useQuery(clientsQuery());
+ const names = useClientNames();
+ const known = (clients.data ?? []).map((one) => ({
+ value: one.ip,
+ label: clientLabel(one.ip, names)?.text ?? one.ip,
+ }));
+ const options = [{ value: ALL_DEVICES, label: "All devices" }, ...known];
+ if (client !== undefined && !known.some((option) => option.value === client)) {
+ options.push({ value: client, label: client });
+ }
+ return options;
+}
+
+export function OverviewToolbar({
+ scope,
+ onChange,
+}: {
+ scope: OverviewScope;
+ onChange: (next: OverviewScope) => void;
+}) {
+ const devices = useDeviceOptions(scope.client);
return (
- onChange(next as Period)}
- className={() => stylex.props(styles.periodGroup).className ?? ""}
- >
- {PERIODS.map((option) => (
-
- stylex.props(
- styles.period,
- isSelected ? styles.periodSelected : styles.periodIdle,
- isFocusVisible && styles.periodFocusVisible,
- ).className ?? ""
- }
- >
- {option}
-
- ))}
-
+
@@ -150,17 +164,33 @@ export function OverviewFrame({
}
/**
- * The route's pending surface. The picker stays live because it only writes the
- * search parameter, which the route already re-reads on its own.
+ * The scope the URL names, and the navigation that rewrites it. The default
+ * period and the household scope are the absence of a parameter, so a link to
+ * the plain page stays `/overview` rather than growing `?period=24h`.
+ */
+export function useOverviewScope(): [OverviewScope, (next: OverviewScope) => void] {
+ const search = useSearch({ from: "/shell/overview" });
+ const navigate = useNavigate({ from: "/overview" });
+ const scope = { period: search.period ?? DEFAULT_PERIOD, client: search.client };
+ const setScope = (next: OverviewScope) =>
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ period: next.period === DEFAULT_PERIOD ? undefined : next.period,
+ client: next.client,
+ }),
+ });
+ return [scope, setScope];
+}
+
+/**
+ * The route's pending surface. The toolbar stays live because it only writes
+ * the search parameters, which the route already re-reads on its own.
*/
export function OverviewPending() {
- const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
- const navigate = useNavigate({ from: "/overview" });
+ const [scope, setScope] = useOverviewScope();
return (
- void navigate({ search: (prev) => ({ ...prev, period: next }) })}
- >
+
);
diff --git a/admin/src/features/overview/OverviewPage.test.tsx b/admin/src/features/overview/OverviewPage.test.tsx
index 0057250..3dd286b 100644
--- a/admin/src/features/overview/OverviewPage.test.tsx
+++ b/admin/src/features/overview/OverviewPage.test.tsx
@@ -14,7 +14,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
-import { clientKey, qtypeKey, seriesColor } from "./seriesColors";
+import { clientSeriesColor, typeRampColor } from "./seriesColors";
import { health } from "@/lib/healthFixture";
import type { Health, Overview } from "@/lib/types";
@@ -144,6 +144,26 @@ function panel(name: string): HTMLElement {
return section;
}
+/** The toolbar's two selectors, named by their aria-label (RAC folds the value into the name too). */
+function periodTrigger(): HTMLElement {
+ return screen.getByRole("button", { name: /Period/ });
+}
+
+function deviceTrigger(): HTMLElement {
+ return screen.getByRole("button", { name: /Device/ });
+}
+
+/** RAC opens a Select from the keyboard as readily as from a pointer. */
+function open(trigger: HTMLElement) {
+ fireEvent.keyDown(trigger, { key: "Enter" });
+ fireEvent.keyUp(trigger, { key: "Enter" });
+}
+
+/** Whether any request so far carried this query string fragment. */
+function requested(fragment: string): boolean {
+ return vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes(fragment));
+}
+
test("the root path lands on Overview rather than aliasing it", async () => {
const router = renderApp("/");
await screen.findByRole("heading", { name: "Overview", level: 1 });
@@ -155,7 +175,7 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
// one panel sharing a hue. The stroke is what stops neighbours from merging
// into one shape, which makes it part of the contract rather than decoration.
renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
const arcs = Array.from(panel("Query types").querySelectorAll("svg path"));
@@ -167,16 +187,20 @@ test("every donut arc is outlined, so two slices of one hue still read as two",
}
});
-test("the page builds a donut slice's colour from the entry's identity", async () => {
+test("the types ring steps the accent's ramp from the busiest type outward", async () => {
// `Donut` renders the colour it is handed and never recomputes one, so the
- // mapping from identity to hue is the page's job and is pinned here.
+ // mapping from rank to ramp step is the page's job and is pinned here.
renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
await waitFor(() => expect(within(panel("Query types")).getAllByText("A")).toHaveLength(2));
- const item = within(panel("Query types")).getAllByText("A")[0].closest("li") as HTMLElement;
- const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
- expect(swatch.getAttribute("style")).toContain(seriesColor(qtypeKey(1)));
+ const swatchOf = (label: string) => {
+ const item = within(panel("Query types")).getAllByText(label)[0].closest("li") as HTMLElement;
+ return (item.querySelector("span[aria-hidden]") as HTMLElement).getAttribute("style");
+ };
+ expect(swatchOf("A")).toContain(typeRampColor(0));
+ expect(swatchOf("AAAA")).toContain(typeRampColor(1));
+ expect(swatchOf("Unknown")).toContain(typeRampColor(2));
});
test("a request in flight leaves the heading and the picker usable behind one loading surface", async () => {
@@ -189,7 +213,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
- expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
+ expect(periodTrigger().textContent).toContain("Last 24 hours");
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
@@ -198,7 +222,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
release();
delayed = null;
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
expect(screen.queryByText("Loading…")).toBeNull();
});
@@ -234,9 +258,9 @@ test("a client named only by reverse DNS is named by it too", async () => {
await waitFor(() => expect(within(chart).getAllByText("laptop.lan")).toHaveLength(2));
});
-test("naming a client does not recolour its series", async () => {
+test("naming a client does not recolour its series: colour goes by rank", async () => {
// The rename the palette must not notice: the swatch beside "kitchen-pi" is
- // the colour of the address it was drawn under, not of the label on screen.
+ // the busiest client's hue, whatever the label on screen says.
registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
renderApp();
const chart = await waitFor(() => panel("Client activity over time"));
@@ -244,7 +268,7 @@ test("naming a client does not recolour its series", async () => {
const item = within(chart).getAllByText("kitchen-pi")[0].closest("li") as HTMLElement;
const swatch = item.querySelector("span[aria-hidden]") as HTMLElement;
- expect(swatch.getAttribute("style")).toContain(seriesColor(clientKey("192.0.2.30")));
+ expect(swatch.getAttribute("style")).toContain(clientSeriesColor(0));
});
test("the client chart drops Other in a period where it counted nothing", async () => {
@@ -262,14 +286,24 @@ test("the client chart drops Other in a period where it counted nothing", async
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
});
-test("the page is four tiles, two charts and two donuts — no status or issues sections", async () => {
+test("the page is four tiles, two charts, the cache card and two donuts — no status or issues sections", async () => {
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
- for (const name of ["Queries over time", "Client activity over time", "Query types", "Upstream servers"]) {
+ for (const name of [
+ "Queries over time",
+ "Client activity over time",
+ "Cache hit rate",
+ "Query types",
+ "Upstream servers",
+ ]) {
expect(screen.getByRole("heading", { name })).toBeTruthy();
}
+ // Every card leads with its title and a one-line description under it.
+ for (const section of screen.getAllByRole("region")) {
+ expect(section.querySelector("h2 + p")?.textContent).toBeTruthy();
+ }
// The sections the layout ruling removed, and the widgets the Dashboard lost.
expect(screen.queryByRole("heading", { name: "Current status" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Active issues" })).toBeNull();
@@ -280,11 +314,18 @@ test("the page is four tiles, two charts and two donuts — no status or issues
test("the four tiles report the window, and each links where its number leads", async () => {
renderApp();
- const tiles = within((await screen.findByText("1,000")).closest("dl") as HTMLElement);
+ await screen.findAllByText("1,000");
+ const tiles = within(screen.getByRole("list", { name: "Totals" }));
+ expect(tiles.getByText("1,000")).toBeTruthy();
expect(tiles.getByText("250")).toBeTruthy();
- expect(tiles.getByText("25.0%")).toBeTruthy();
+ expect(tiles.getByText("25.00%")).toBeTruthy();
expect(tiles.getByText("7")).toBeTruthy();
- expect(tiles.getByText("2.3 ms")).toBeTruthy();
+ expect(tiles.getAllByRole("listitem").map((item) => item.querySelector("p + p")?.textContent)).toEqual([
+ "queries",
+ "blocked queries",
+ "active clients",
+ "of queries blocked",
+ ]);
// The bounds are the ones the stats response returned, not ones computed here.
const queries = new URLSearchParams(
@@ -306,9 +347,23 @@ test("the four tiles report the window, and each links where its number leads",
expect(screen.queryByRole("link", { name: /average/i })).toBeNull();
});
+test("the cache card reads the hit share off the buckets and the forwarded count off the routes", async () => {
+ renderApp();
+ await screen.findAllByText("1,000");
+
+ const cache = within(panel("Cache hit rate"));
+ // 10 cached answers of 1,000 queries; 500 + 100 went to an upstream.
+ expect(cache.getByText("1.00%")).toBeTruthy();
+ expect(cache.getByRole("img", { name: "1.00% of queries served from cache" })).toBeTruthy();
+ expect(cache.getByText("10")).toBeTruthy();
+ expect(cache.getByText("600")).toBeTruthy();
+ expect(cache.getByText("2.3 ms")).toBeTruthy();
+ expect(cache.getByText("of queries answered from cache")).toBeTruthy();
+});
+
test("both donuts name every entry, nulls included, and disambiguate a nameless source", async () => {
renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
const types = within(panel("Query types"));
expect(types.getByRole("rowheader", { name: "A" })).toBeTruthy();
@@ -326,7 +381,7 @@ test("both donuts name every entry, nulls included, and disambiguate a nameless
test("the donut ring is decoration; the legend and the hidden table are the accessible surface", async () => {
renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
const svg = panel("Query types").querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
@@ -336,47 +391,87 @@ test("the donut ring is decoration; the legend and the hidden table are the acce
test("an empty window says so in every panel instead of drawing nothing", async () => {
renderApp("/overview?period=1h");
- await screen.findByText("12");
+ await screen.findAllByText("12");
// The two donuts and the client chart; the query-volume chart says it too.
expect(screen.getAllByText("No queries in this period.").length).toBe(4);
});
-test("a deep link opens on the period it names", async () => {
+test("a deep link opens on the period it names, and the picker offers the four in words", async () => {
renderApp("/overview?period=1h");
- await screen.findByText("12");
- // One radio group named Period, holding the four periods and exactly one
- // selection: the segmented picker is a single choice, not four toggles.
- const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
- expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
- "1h",
- "24h",
- "7d",
- "30d",
+ await screen.findAllByText("12");
+ expect(periodTrigger().textContent).toContain("Last hour");
+
+ open(periodTrigger());
+ expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
+ "Last hour",
+ "Last 24 hours",
+ "Last 7 days",
+ "Last 30 days",
]);
- expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
- expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
});
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d");
- await screen.findByText("1,000");
- expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
+ await screen.findAllByText("1,000");
+ expect(periodTrigger().textContent).toContain("Last 24 hours");
expect(router.state.location.search).toEqual({});
});
test("the picker rescopes every panel and writes the period into the url", async () => {
const router = renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
- fireEvent.click(screen.getByRole("radio", { name: "1h" }));
+ open(periodTrigger());
+ fireEvent.click(screen.getByRole("option", { name: "Last hour" }));
- await screen.findByText("12");
+ await screen.findAllByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
// No panel is left describing the period the reader left.
expect(screen.queryByText("1,000")).toBeNull();
});
+test("a deep link to one device scopes the request, the tiles' links and the picker", async () => {
+ renderApp("/overview?client=192.0.2.31");
+ await screen.findAllByText("1,000");
+
+ expect(requested("client=192.0.2.31")).toBe(true);
+ // Not registered, so the picker shows the address rather than claiming the household.
+ expect(deviceTrigger().textContent).toContain("192.0.2.31");
+ const queries = new URLSearchParams(
+ screen.getByRole("link", { name: "Open in Activity" }).getAttribute("href")?.split("?")[1] ?? "",
+ );
+ expect(queries.get("client")).toBe("192.0.2.31");
+});
+
+test("the device picker names the registered clients and writes the choice into the url", async () => {
+ registered = [{ ip: "192.0.2.30", name: "kitchen-pi", learned_name: "" }];
+ const router = renderApp();
+ await screen.findAllByText("1,000");
+ expect(deviceTrigger().textContent).toContain("All devices");
+
+ // The clients list has landed once the chart names the client by it.
+ await waitFor(() => expect(within(panel("Client activity over time")).getAllByText("kitchen-pi")).toHaveLength(2));
+ open(deviceTrigger());
+ fireEvent.click(screen.getByRole("option", { name: "kitchen-pi" }));
+
+ await waitFor(() => expect(router.state.location.search).toEqual({ client: "192.0.2.30" }));
+ await waitFor(() => expect(requested("client=192.0.2.30")).toBe(true));
+ expect(deviceTrigger().textContent).toContain("kitchen-pi");
+
+ // Back to the household drops the parameter rather than writing an empty one.
+ open(deviceTrigger());
+ fireEvent.click(screen.getByRole("option", { name: "All devices" }));
+ await waitFor(() => expect(router.state.location.search).toEqual({}));
+});
+
+test("a client list in the url is not this page's grammar and is dropped", async () => {
+ const router = renderApp("/overview?client=192.0.2.30,192.0.2.31");
+ await screen.findAllByText("1,000");
+ expect(router.state.location.search).toEqual({});
+ expect(requested("client=")).toBe(false);
+});
+
test("a failed request is one error for the whole page, stated once and retryable", async () => {
failing = true;
renderApp();
@@ -388,18 +483,18 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// The heading and the picker survive it, so the reader can rescope or retry.
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
- expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
+ expect(periodTrigger()).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
});
test("an incomplete window states its watermark once for the whole page", async () => {
coverageComplete = false;
renderApp();
- await screen.findByText("1,000");
+ await screen.findAllByText("1,000");
expect(screen.getAllByText(/Query history is available from/)).toHaveLength(1);
});
diff --git a/admin/src/features/overview/OverviewPage.tsx b/admin/src/features/overview/OverviewPage.tsx
index 4afab44..7868f72 100644
--- a/admin/src/features/overview/OverviewPage.tsx
+++ b/admin/src/features/overview/OverviewPage.tsx
@@ -1,12 +1,13 @@
/**
- * Overview: what the resolver did over a period the reader chooses, in the
- * layout Pi-hole's dashboard established — four totals, two full-width charts,
- * two breakdown donuts. Nothing on this page is a current-state readout; the
- * five health conditions live on Diagnostics, and protection lives in the
- * sidebar beside its control.
+ * Overview: what the resolver did over a period the reader chooses, for the
+ * household or for one device, in the layout the decision record settled
+ * (ui-visual-redesign.md) — four totals, two full-width charts, then the cache
+ * rate and the two breakdown rings in a row of cards. Nothing on this page is a
+ * current-state readout; the five health conditions live on Diagnostics, and
+ * protection lives in the sidebar beside its control.
*
- * The period is URL state, so a view is a link: `/overview?period=1h` opens
- * exactly what the sender was reading.
+ * The scope is URL state, so a view is a link: `/overview?period=1h&client=…`
+ * opens exactly what the sender was reading.
*
* One request feeds every panel (`overviewWindow.ts`), so the page has one
* loading state and one error state rather than six: there is no longer a
@@ -14,29 +15,20 @@
*/
import * as stylex from "@stylexjs/stylex";
-import { useNavigate, useSearch } from "@tanstack/react-router";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { qtypeName } from "@/features/provenance/qtype";
import type { Overview, OverviewRouteRow, OverviewTypeRow } from "@/lib/types";
-import { colors } from "@/ui/tokens.stylex";
+import Card from "@/ui/Card";
+import CacheCard from "./CacheCard";
import ClientChart from "./ClientChart";
import Donut from "./Donut";
import StatTiles from "./StatTiles";
import TimeseriesChart from "./TimeseriesChart";
import type { DonutSlice } from "./Donut";
-import { OverviewFrame, OverviewLoading } from "./OverviewFrame";
+import { OverviewFrame, OverviewLoading, useOverviewScope } from "./OverviewFrame";
import { useOverviewWindow, type Panel } from "./overviewWindow";
-import { DEFAULT_PERIOD } from "./period";
-import { qtypeKey, routeKey, seriesColor } from "./seriesColors";
-
-/**
- * Where the two donuts stop competing for width and sit side by side. `Donut`
- * carries the same query for the alignment it switches at that width; StyleX
- * requires the string to be a literal in the module that uses it, so the two
- * agree by inspection rather than by sharing a constant.
- */
-const TWO_COLUMN = "@media (min-width: 1280px)";
+import { qtypeKey, routeKey, seriesColor, typeRampColor } from "./seriesColors";
const ROUTE_LABELS = {
blocked: "Blocked",
@@ -48,31 +40,17 @@ const ROUTE_LABELS = {
} as const;
const styles = stylex.create({
- panel: {
- borderRadius: "0.25rem",
- borderWidth: 1,
- borderStyle: "solid",
- borderColor: colors.border,
- backgroundColor: colors.surfaceRaised,
- paddingInline: "1rem",
- paddingBlock: "0.75rem",
- },
- panelHeading: {
- marginBottom: "0.75rem",
- fontSize: "0.875rem",
- lineHeight: "1.25rem",
- fontWeight: 600,
- },
- donutRow: {
+ /** As many cards per row as fit at 20rem each: three on a desktop, one on a phone. */
+ cards: {
display: "grid",
- gap: "1rem",
- gridTemplateColumns: { default: "minmax(0, 1fr)", [TWO_COLUMN]: "repeat(2, minmax(0, 1fr))" },
+ gap: "1.25rem",
+ gridTemplateColumns: "repeat(auto-fit, minmax(20rem, 1fr))",
},
});
/**
- * The page's three states. The heading and the period picker stay put through
- * all three, so the reader can rescope or retry without waiting for anything.
+ * The page's three states. The heading and the toolbar stay put through all
+ * three, so the reader can rescope or retry without waiting for anything.
*/
function PageBody({ panel, children }: { panel: Panel; children: (data: Overview) => React.ReactNode }) {
if (panel.status === "error") return ;
@@ -80,12 +58,13 @@ function PageBody({ panel, children }: { panel: Panel; children: (data
return <>{children(panel.data)}>;
}
+/** The API ranks the types by count, so the ramp's darkest step is the busiest type. */
function typeSlices(types: OverviewTypeRow[]): DonutSlice[] {
- return types.map((row) => ({
+ return types.map((row, rank) => ({
key: qtypeKey(row.qtype),
label: row.qtype === null ? "Unknown" : qtypeName(row.qtype),
value: row.count,
- color: seriesColor(qtypeKey(row.qtype)),
+ color: typeRampColor(rank),
}));
}
@@ -108,56 +87,67 @@ function routeSlices(routes: OverviewRouteRow[]): DonutSlice[] {
});
}
+/** What the cache card reads: hits are the buckets' cached counts, forwarded is every upstream or zone answer. */
+function cacheOf(data: Overview) {
+ return {
+ queries: data.totals.queries,
+ hits: data.buckets.reduce((sum, bucket) => sum + bucket.cached, 0),
+ forwarded: data.routes
+ .filter((row) => row.route === "upstream" || row.route === "forward_zone")
+ .reduce((sum, row) => sum + row.count, 0),
+ avg_response_time_us: data.totals.avg_response_time_us,
+ };
+}
+
export default function OverviewPage() {
- const period = useSearch({ from: "/shell/overview" }).period ?? DEFAULT_PERIOD;
- const navigate = useNavigate({ from: "/overview" });
- const overview = useOverviewWindow(period);
+ const [scope, setScope] = useOverviewScope();
+ const overview = useOverviewWindow(scope.period, scope.client);
return (
- void navigate({ search: (prev) => ({ ...prev, period: next }) })}
- >
+
{(data) => (
<>
-
+
{/* One notice for the page: every panel came out of this one
response, so a second copy would only repeat this sentence. */}
-
-
- Queries over time
-
+
-
+
-
-
- Client activity over time
-
+
-
+
-
-
-
- Query types
-
+
+
+
-
-
-
- Upstream servers
-
+
+
-
+
>
)}
diff --git a/admin/src/features/overview/StatTiles.tsx b/admin/src/features/overview/StatTiles.tsx
index 424b470..c5f2a1d 100644
--- a/admin/src/features/overview/StatTiles.tsx
+++ b/admin/src/features/overview/StatTiles.tsx
@@ -1,110 +1,128 @@
/**
- * The window's four headline numbers, each with the way into the rows behind it.
+ * The window's four headline numbers (ui-visual-redesign.md): centred 2.5rem
+ * numerals over lowercase captions, each with the way into the rows behind it.
+ * Colour is semantic and nothing else — the blocked count is red, the share is
+ * muted, the rest is ink — so a tile never implies a state it is not reporting.
*
- * Neutral chrome throughout: no coloured accents, no per-tile tone. Emphasis is
- * typographic, so the eye ranks the figures rather than the panels, and a tile
- * never implies a state it is not reporting.
+ * On a phone the four tiles merge into one card: the three counts side by side
+ * and the share on a line under them, so the set fits above the fold.
*
* The Activity links carry the bounds the **overview response** returned, not
* bounds computed here — a client-computed window would send the reader to a
- * slightly different span than the one they were just reading.
+ * slightly different span than the one they were just reading — and the client
+ * scope the page is under, so the rows they open are the rows the tile counted.
*/
import * as stylex from "@stylexjs/stylex";
import { Link } from "@tanstack/react-router";
-import { formatMicros } from "@/lib/format";
+import { formatCount, formatPercent } from "@/lib/format";
import type { OverviewTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
-import { colors } from "@/ui/tokens.stylex";
+import { colors, metrics } from "@/ui/tokens.stylex";
-const numberFormat = new Intl.NumberFormat();
+const NARROW = "@media (max-width: 800px)";
const styles = stylex.create({
- /** Two columns on a phone, the whole set of four in one row from `md`. */
grid: {
display: "grid",
- gap: "0.75rem",
- gridTemplateColumns: {
- default: "repeat(2, minmax(0, 1fr))",
- "@media (min-width: 768px)": "repeat(4, minmax(0, 1fr))",
- },
+ gap: { default: "1rem", [NARROW]: 0 },
+ gridTemplateColumns: { default: "repeat(4, minmax(0, 1fr))", [NARROW]: "repeat(3, minmax(0, 1fr))" },
+ margin: 0,
+ padding: 0,
+ listStyleType: "none",
+ borderRadius: { default: null, [NARROW]: metrics.radius },
+ borderWidth: { default: 0, [NARROW]: 1 },
+ borderStyle: "solid",
+ borderColor: colors.border,
+ backgroundColor: { default: null, [NARROW]: colors.surfaceRaised },
+ paddingInline: { default: 0, [NARROW]: "0.5rem" },
+ paddingBlock: { default: 0, [NARROW]: "1rem" },
},
tile: {
display: "flex",
flexDirection: "column",
- gap: "0.125rem",
- borderRadius: "0.25rem",
- borderWidth: 1,
+ alignItems: "center",
+ gap: "0.375rem",
+ minWidth: 0,
+ borderRadius: metrics.radius,
+ borderWidth: { default: 1, [NARROW]: 0 },
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
- paddingInline: "1rem",
- paddingBlock: "0.75rem",
+ paddingInline: { default: "1rem", [NARROW]: "0.25rem" },
+ paddingBlock: { default: "1.5rem", [NARROW]: 0 },
+ textAlign: "center",
},
- label: {
+ /** The share drops under the three counts on a phone, divided from them by a hairline. */
+ shareTile: {
+ gridColumn: { default: null, [NARROW]: "1 / -1" },
+ marginTop: { default: 0, [NARROW]: "1rem" },
+ paddingTop: { default: null, [NARROW]: "1rem" },
+ borderTopWidth: { default: null, [NARROW]: 1 },
+ borderTopStyle: "solid",
+ borderTopColor: colors.border,
+ },
+ value: {
+ margin: 0,
+ fontSize: { default: "2.5rem", [NARROW]: "1.75rem" },
+ lineHeight: 1,
+ fontWeight: 400,
+ letterSpacing: "-0.02em",
+ color: colors.text,
+ },
+ valueBlocked: { color: colors.chartRed },
+ valueMuted: { color: colors.textMuted },
+ caption: {
+ margin: 0,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
- valueRow: {
- display: "flex",
- alignItems: "baseline",
- gap: "0.5rem",
- flexWrap: "wrap",
- },
- value: {
- fontSize: "1.875rem",
- lineHeight: "2.25rem",
- fontWeight: 600,
- },
- detail: {
- fontSize: "0.875rem",
- lineHeight: "1.25rem",
- color: colors.textSecondary,
- },
- footer: {
+ link: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
- },
- link: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
});
-function percentOf(part: number, total: number): string | null {
- if (total === 0) return null;
- return `${((part / total) * 100).toFixed(1)}%`;
-}
-
function Tile({
- label,
value,
- detail,
- footer,
+ caption,
+ tone,
+ style,
+ children,
}: {
- label: string;
value: string;
- detail?: string | null;
- footer?: React.ReactNode;
+ caption: string;
+ tone?: "blocked" | "muted";
+ style?: stylex.StyleXStyles;
+ children?: React.ReactNode;
}) {
return (
-
-
{label}
-
- {value}
- {detail != null && {detail}}
-
- {footer !== undefined &&
{footer}
}
-
+
+
+ {value}
+
+
{caption}
+ {children}
+
);
}
-/** The window's totals with the bounds they were measured over. */
+/** The window's totals with the bounds they were measured over and the scope they were read under. */
export interface StatTilesData extends OverviewTotals {
since: number;
until: number;
+ client: string | undefined;
}
export default function StatTiles({ stats }: { stats: StatTilesData }) {
@@ -113,50 +131,39 @@ export default function StatTiles({ stats }: { stats: StatTilesData }) {
since: stats.since,
until: stats.until,
domain: undefined,
- client: undefined,
+ client: stats.client,
};
return (
-
+
);
}
diff --git a/admin/src/features/overview/TimeseriesChart.test.tsx b/admin/src/features/overview/TimeseriesChart.test.tsx
index 5cb21bb..e36783e 100644
--- a/admin/src/features/overview/TimeseriesChart.test.tsx
+++ b/admin/src/features/overview/TimeseriesChart.test.tsx
@@ -3,10 +3,17 @@ import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
+import { CHART_BLUE, CHART_RED } from "./seriesColors";
import TimeseriesChart, { type TimeseriesData } from "./TimeseriesChart";
const SINCE = 1_700_000_000;
+/** The fallback width less the margins: where the plot starts and ends. */
+const PLOT_LEFT = 44;
+const PLOT_RIGHT = 44 + 588;
+const PLOT_TOP = 8;
+const PLOT_BOTTOM = 240 - 22;
+
function timeseries(buckets: Bucket[]): TimeseriesData {
return { since: SINCE, bucket_seconds: 1800, buckets };
}
@@ -34,6 +41,18 @@ function overlayRects(container: HTMLElement): SVGRectElement[] {
return Array.from(container.querySelectorAll('rect[fill="transparent"]'));
}
+/** The area fill and the line of one series. */
+function seriesPaths(container: HTMLElement, key: string): { area: SVGPathElement; line: SVGPathElement } {
+ const group = container.querySelector(`g[data-series="${key}"]`) as SVGGElement;
+ const [area, line] = Array.from(group.querySelectorAll("path"));
+ return { area, line };
+}
+
+/** Every coordinate pair in a path, in drawing order. */
+function pathPoints(d: string): [number, number][] {
+ return Array.from(d.matchAll(/(-?[\d.]+),(-?[\d.]+)/g)).map((match) => [Number(match[1]), Number(match[2])]);
+}
+
test("the data table is the SVG's accessible equivalent", () => {
render();
@@ -57,13 +76,13 @@ test("the hidden data table is clipped by a block wrapper, not by the table itse
});
/**
- * "Allowed" is what the reported total leaves over, and the three counts come
+ * "Allowed" is what the reported total leaves over, and the two counts come
* from separate columns that a partial write can leave inconsistent. A negative
- * remainder would draw a segment upside down.
+ * remainder would be a lie in the table.
*/
test("allowed is the remainder of the reported total, clamped at zero", () => {
const { container } = render(
- ,
+ ,
);
const row = within(screen.getByRole("table")).getAllByRole("row")[1];
@@ -71,13 +90,9 @@ test("allowed is the remainder of the reported total, clamped at zero", () => {
within(row)
.getAllByRole("cell")
.map((cell) => cell.textContent),
- ).toEqual(["10", "8", "5", "0"]);
- // Blocked and cached are drawn; the empty "allowed" segment is not.
- expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);
+ ).toEqual(["10", "12", "0"]);
- // The scale comes from the reported total, not from the stack's own sum.
- // Scaling to the sum would reach 13 here and leave the bar four fifths of the
- // way up a plot whose own numbers say it is full.
+ // The scale comes from the reported total, so the plot's own numbers say it is full.
const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
expect(ticks[ticks.length - 1]).toBe("10");
});
@@ -102,18 +117,80 @@ test("no bucket at all says the same thing", () => {
});
/**
- * The three category colours are fixed constants of this chart rather than
- * anything derived from `seriesColors`, which would paint "other" grey.
+ * Two series, each a line over its own fill, in the two colours the decision
+ * record fixed: the accent blue for the total, the softer red for blocked. The
+ * total is drawn first so the blocked band paints over its faint fill.
*/
-test("the segments are drawn in this chart's own category colours", () => {
+test("each series is a line over a fill in its own colour, blocked painted over the total", () => {
const { container } = render(
,
);
- const fills = Array.from(container.querySelectorAll("rect"))
- .map((rect) => rect.getAttribute("fill"))
- .filter((fill) => fill !== "transparent");
- expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
+ const groups = Array.from(container.querySelectorAll("g[data-series]")).map((g) => g.getAttribute("data-series"));
+ expect(groups).toEqual(["queries", "blocked"]);
+
+ const total = seriesPaths(container, "queries");
+ expect(total.area.getAttribute("fill")).toBe(CHART_BLUE);
+ expect(total.area.getAttribute("fill-opacity")).toBe("0.13");
+ expect(total.line.getAttribute("stroke")).toBe(CHART_BLUE);
+ expect(total.line.getAttribute("stroke-width")).toBe("2");
+ expect(total.line.getAttribute("fill")).toBe("none");
+
+ const blocked = seriesPaths(container, "blocked");
+ expect(blocked.area.getAttribute("fill")).toBe(CHART_RED);
+ expect(blocked.area.getAttribute("fill-opacity")).toBe("0.35");
+ expect(blocked.line.getAttribute("stroke")).toBe(CHART_RED);
+});
+
+/**
+ * The owner could not see past the last labelled point when the points sat at
+ * band centres: the first bucket is on the plot's left edge and the last on its
+ * right, so the curve covers the whole plot and ends on its data.
+ */
+test("the points run edge to edge, and the curve passes through every one of them", () => {
+ const { container } = render(
+ ,
+ );
+
+ const points = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "");
+ // M, then two Cs of three pairs each: the last pair of each C is the data point.
+ expect(points).toHaveLength(7);
+ expect(points[0]).toEqual([PLOT_LEFT, PLOT_TOP]);
+ expect(points[3][0]).toBeCloseTo((PLOT_LEFT + PLOT_RIGHT) / 2, 1);
+ expect(points[6]).toEqual([PLOT_RIGHT, PLOT_TOP]);
+ // The fill closes down to the baseline under the same two ends.
+ expect(seriesPaths(container, "queries").area.getAttribute("d")).toMatch(
+ new RegExp(` L${PLOT_RIGHT},${PLOT_BOTTOM} L${PLOT_LEFT},${PLOT_BOTTOM} Z$`),
+ );
+});
+
+/**
+ * A spline through a spike overshoots. Clamping the control points keeps the
+ * curve inside the plot, so a zero bucket beside a busy one never dips below
+ * the baseline or above the top.
+ */
+test("the curve never leaves the plot, whatever the data does", () => {
+ const { container } = render(
+ ,
+ );
+
+ for (const [, y] of pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")) {
+ expect(y).toBeGreaterThanOrEqual(PLOT_TOP);
+ expect(y).toBeLessThanOrEqual(PLOT_BOTTOM);
+ }
});
/**
@@ -126,7 +203,7 @@ test("hover text is the tooltip alone, never a bare SVG title", () => {
expect(container.querySelectorAll("title")).toHaveLength(0);
});
-/** The hit target is the whole column slot, including the space above a short stack. */
+/** The hit target is the whole column slot, including the space above a low point. */
test("each bucket's hit target spans the full plot height", () => {
const { container } = render();
@@ -139,20 +216,30 @@ test("each bucket's hit target spans the full plot height", () => {
});
/**
- * A band scale spends a gap after the last column as well as between them, so a
- * full-step hit target on the last bucket would reach into the right margin and
- * catch pointers that are past the plot entirely.
+ * The bands tile the plot with their boundaries midway between neighbouring
+ * points, so landing anywhere in the plot is already "snap to the nearest
+ * bucket" and no pixel belongs to no bucket.
*/
-test("the last hit target stops at the plot's right edge", () => {
+test("the hit targets tile the plot edge to edge, split midway between points", () => {
const { container } = render();
- const last = overlayRects(container).at(-1) as SVGRectElement;
- const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
- // 640 fallback width, less the 44px left and 8px right margins.
- expect(right).toBeCloseTo(44 + 588, 6);
+ const edges = overlayRects(container).map((rect) => {
+ const x = Number(rect.getAttribute("x"));
+ return [x, x + Number(rect.getAttribute("width"))];
+ });
+ const middle = (PLOT_LEFT + PLOT_RIGHT) / 2;
+ expect(edges[0][0]).toBe(PLOT_LEFT);
+ expect(edges[0][1]).toBeCloseTo((PLOT_LEFT + middle) / 2, 6);
+ expect(edges[1][0]).toBeCloseTo(edges[0][1], 6);
+ expect(edges[2][1]).toBe(PLOT_RIGHT);
});
-test("pointing at a bucket names its total and every series, and dims the rest", () => {
+/**
+ * The owner's ask: a dot on each curve and a guide line, so the reader can see
+ * which point the tooltip describes. The dots sit at the coordinates the curves
+ * were built from, ringed in the panel's surface so they read over either fill.
+ */
+test("pointing at a bucket marks it with a guide line and a dot on each curve", () => {
const { container } = render(
,
);
+ expect(container.querySelector("g[data-hover]")).toBeNull();
+
+ fireEvent.mouseOver(overlayRects(container)[1]);
+
+ const hover = container.querySelector("g[data-hover]") as SVGGElement;
+ const guide = hover.querySelector("line") as SVGLineElement;
+ expect(guide.getAttribute("x1")).toBe(String(PLOT_RIGHT));
+ expect(guide.getAttribute("y1")).toBe(String(PLOT_TOP));
+ expect(guide.getAttribute("y2")).toBe(String(PLOT_BOTTOM));
+
+ const dots = Array.from(hover.querySelectorAll("circle"));
+ expect(dots.map((dot) => dot.getAttribute("fill"))).toEqual([CHART_BLUE, CHART_RED]);
+ const lastPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "").at(-1);
+ expect(Number(dots[0].getAttribute("cx"))).toBe(PLOT_RIGHT);
+ expect(Number(dots[0].getAttribute("cy"))).toBeCloseTo(lastPoint?.[1] ?? NaN, 1);
+ for (const dot of dots) expect(dot.getAttribute("r")).toBe("4");
+});
+
+test("pointing at a bucket names the total, the blocked share and the remainder", () => {
+ const { container } = render(
+ ,
+ );
fireEvent.mouseOver(overlayRects(container)[0]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
- const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
- expect(values).toEqual(["100", "40", "10", "50"]);
const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
- expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
- // One swatch per series, in the colour the segment is drawn in.
+ expect(terms).toEqual(["Total queries", "Blocked", "Allowed"]);
+ const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
+ expect(values).toEqual(["1,000", "400", "600"]);
+ // One swatch per drawn series; the remainder is arithmetic, not a shape.
const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
- expect(swatches[0]).toContain("#ef4444");
- expect(swatches[1]).toContain("#059669");
- expect(swatches[2]).toContain("#3b82f6");
-
- const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
- expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
+ expect(swatches).toHaveLength(2);
+ expect(swatches[0]).toContain(CHART_BLUE);
+ expect(swatches[1]).toContain(CHART_RED);
});
/**
- * The tooltip sits 8px down from the chart's top edge and 8px to the side of the
- * slot it names — the placement the hand-rolled tooltip had, restored over
- * visx's own 10px defaults.
+ * Beside the point, never over it: 16px to its right at the point's own height,
+ * so the box hides neither the dot nor the slot of data the reader is reading.
+ * `TooltipWithBounds` flips it to the left when it would run off the right edge.
*/
-test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
+test("the tooltip stands 16px beside the point, at the point's own height", () => {
const { container } = render();
fireEvent.mouseOver(overlayRects(container)[0]);
- // The first slot spans 44 to 44 + step, so its centre is 191.5 and the
- // tooltip sits 8px right of it. visx rounds the placement to whole pixels.
const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
- expect(tooltip.style.transform).toBe("translate(200px, 8px)");
+ const firstPoint = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[0];
+ expect(tooltip.style.transform).toBe(`translate(${PLOT_LEFT + 16}px, ${Math.round(firstPoint[1])}px)`);
});
/**
@@ -232,7 +342,7 @@ test("a refresh in the same window retells the hovered bucket with the new count
fireEvent.mouseOver(overlayRects(container)[0]);
expect(
Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
- ).toEqual(["100", "40", "10", "50"]);
+ ).toEqual(["100", "40", "60"]);
rerender(
dd.textContent,
);
- expect(values).toEqual(["120", "60", "10", "50"]);
+ expect(values).toEqual(["120", "60", "60"]);
});
/**
* A rolling window is the case a stored copy gets wrong: the bucket the pointer
- * was over is gone, so index 0 now names a different span. The tooltip goes away
- * rather than describing a bucket that is no longer drawn, and nothing stays
- * dimmed behind it. Rolling back to the earlier window must not bring it back
- * either: the selection is deleted when the window moves, not held aside.
+ * was over is gone, so index 0 now names a different span. The tooltip and the
+ * marks go away rather than describing a bucket that is no longer drawn.
+ * Rolling back to the earlier window must not bring them back either: the
+ * selection is deleted when the window moves, not held aside.
*/
-test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
+test("a refresh that rolls the window takes the tooltip and the marks down", () => {
const { container, rerender } = render(
g.visx-group[opacity]"));
- expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
+ expect(container.querySelector("g[data-hover]")).toBeNull();
rerender(
{
const { container, rerender } = render(
@@ -319,7 +429,7 @@ test("a bucket whose numbers change is remounted, so it is measured again", () =
expect(second).not.toBe(first);
});
-test("leaving the chart takes the tooltip and the dimming with it", () => {
+test("leaving the chart takes the tooltip and the marks with it", () => {
const { container } = render();
fireEvent.mouseOver(overlayRects(container)[0]);
@@ -327,61 +437,36 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0);
- const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
- expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
+ expect(container.querySelector("g[data-hover]")).toBeNull();
});
/**
- * The third series is every query neither blocked nor served from cache. It was
- * called "Other", which named the arithmetic rather than the thing.
+ * The legend names the two drawn curves and nothing else; the table adds the
+ * remainder, because a reader of the table has no fill to read it off.
*/
-test("the remainder series is called Allowed everywhere it surfaces", () => {
+test("the legend names the two curves, and the table adds the remainder", () => {
const { container } = render(
,
);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
- expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
+ expect(legend).toEqual(["Total queries", "Blocked"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
- ).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);
-
- fireEvent.mouseOver(overlayRects(container)[0]);
- const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
- expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
+ ).toEqual(["Time", "Queries", "Blocked", "Allowed"]);
expect(screen.queryByText("Other")).toBeNull();
+ expect(screen.queryByText("Cached")).toBeNull();
});
-/**
- * A window where everything was blocked or served from cache has no allowed
- * queries, and that is worth reading rather than hiding: an absent series would
- * say the same thing as a series nobody looked at. The three categories are all
- * real answers a query can get, so none of them is dropped for counting zero.
- * The client chart's "Other" is dropped at zero, but that one aggregates clients
- * beyond the top eight rather than naming a kind of answer.
- */
-test("a window with nothing allowed keeps the series at zero", () => {
- const { container } = render(
- ,
- );
+test("the hidden table groups its counts like every other figure on the page", () => {
+ render();
- const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
- expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
-
- fireEvent.mouseOver(overlayRects(container)[0]);
- const tooltip = container.querySelector("dl") as HTMLElement;
- expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
- "Queries",
- "Blocked",
- "Cached",
- "Allowed",
- ]);
- expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
+ const row = within(screen.getByRole("table")).getAllByRole("row")[1];
+ expect(
+ within(row)
+ .getAllByRole("cell")
+ .map((cell) => cell.textContent),
+ ).toEqual(["12,345", "1,234", "11,111"]);
});
diff --git a/admin/src/features/overview/TimeseriesChart.tsx b/admin/src/features/overview/TimeseriesChart.tsx
index 94b987b..0049bf9 100644
--- a/admin/src/features/overview/TimeseriesChart.tsx
+++ b/admin/src/features/overview/TimeseriesChart.tsx
@@ -1,81 +1,69 @@
+/**
+ * Query volume over the window as a smoothed area: every query in blue, the
+ * blocked share in red along the bottom (ui-visual-redesign.md). Pointing at
+ * the plot selects the nearest bucket and marks it with a guide line and a dot
+ * on each curve; the tooltip sits beside the point so it never covers the slot
+ * the reader is looking at.
+ */
+
import * as stylex from "@stylexjs/stylex";
-import { Group } from "@visx/group";
-import { BarStack } from "@visx/shape";
-import { formatTime } from "@/lib/format";
+import { formatCount, formatTime } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
- BucketOverlay,
CHART_HEIGHT,
ChartFrame,
+ ChartLegend,
ChartRoot,
ChartTooltip,
EmptyChart,
- StackSegment,
- bandScale,
+ HitBands,
+ areaPath,
labelTickValues,
plotArea,
- slotCenter,
+ pointScale,
+ smoothPath,
useActiveIndex,
useMeasuredWidth,
valueScale,
valueTicks,
type TooltipContent,
} from "./chartKit";
+import { CHART_BLUE, CHART_RED } from "./seriesColors";
-// Series colors validated for CVD separation and 3:1 surface contrast in both
-// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
-// The third key stays `other` — it is the colour key and the response field, and
-// renaming it would repaint the series. Only what the reader sees is "Allowed".
+/**
+ * Drawn bottom-up in this order: the total's fill is faint so the blocked
+ * curve, painted over it, stays a solid band along the baseline.
+ */
const SERIES = [
- { key: "blocked", label: "Blocked", color: "#ef4444" },
- { key: "cached", label: "Cached", color: "#059669" },
- { key: "other", label: "Allowed", color: "#3b82f6" },
+ { key: "queries", label: "Total queries", color: CHART_BLUE, fillOpacity: 0.13 },
+ { key: "blocked", label: "Blocked", color: CHART_RED, fillOpacity: 0.35 },
] as const;
-type Series = (typeof SERIES)[number];
-type SeriesKey = Series["key"];
+type SeriesKey = (typeof SERIES)[number]["key"];
-const SERIES_COLOR: Record = {
- blocked: SERIES[0].color,
- cached: SERIES[1].color,
- other: SERIES[2].color,
-};
+const LINE_WIDTH = 2;
+const DOT_RADIUS = 4;
const styles = stylex.create({
- legend: {
- marginTop: "0.5rem",
- display: "flex",
- flexWrap: "wrap",
- columnGap: "1rem",
- rowGap: "0.25rem",
- fontSize: "0.75rem",
- lineHeight: "1rem",
- color: colors.textSecondary,
+ guide: {
+ stroke: colors.borderStrong,
+ strokeWidth: 1,
},
- legendItem: {
- display: "flex",
- alignItems: "center",
- gap: "0.375rem",
+ /** The dot's ring is the panel's own surface, so it stays legible over either fill. */
+ dot: {
+ stroke: colors.surfaceRaised,
+ strokeWidth: 2,
},
- swatch: {
- display: "inline-block",
- width: "0.625rem",
- height: "0.625rem",
- borderRadius: "0.125rem",
- },
- /** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
- swatchColor: (color: string) => ({ backgroundColor: color }),
});
interface Column {
ts: number;
queries: number;
blocked: number;
- cached: number;
- /** queries - blocked - cached, clamped at 0. */
- other: number;
+ /** queries - blocked, clamped at 0. */
+ allowed: number;
}
function columnsOf(buckets: Bucket[]): Column[] {
@@ -83,8 +71,7 @@ function columnsOf(buckets: Bucket[]): Column[] {
ts: bucket.ts,
queries: bucket.queries,
blocked: bucket.blocked,
- cached: bucket.cached,
- other: Math.max(0, bucket.queries - bucket.blocked - bucket.cached),
+ allowed: Math.max(0, bucket.queries - bucket.blocked),
}));
}
@@ -92,13 +79,13 @@ function tooltipOf(column: Column): TooltipContent {
return {
title: formatTime(column.ts),
rows: [
- { key: "queries", label: "Queries", value: String(column.queries) },
...SERIES.map((series) => ({
key: series.key,
label: series.label,
color: series.color,
- value: String(column[series.key]),
+ value: formatCount(column[series.key]),
})),
+ { key: "allowed", label: "Allowed", value: formatCount(column.allowed) },
],
};
}
@@ -126,17 +113,18 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const columns = columnsOf(data.buckets);
const timestamps = columns.map((column) => column.ts);
const plot = plotArea(width);
- const xScale = bandScale(timestamps, plot);
- // The scale is the reported total rather than the stack's own sum: blocked
- // and cached are parts of `queries`, which a clamped `other` can undercount.
+ const xScale = pointScale(timestamps, plot);
const yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
const yTicks = valueTicks(yScale);
+ const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
+ const pointsOf = (key: SeriesKey): [number, number][] =>
+ columns.map((column, i) => [centers[i], yScale(column[key])]);
return (
{hovered.index !== null && (
)}
-
@@ -205,21 +200,17 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
Time
Queries
- {SERIES.map((series) => (
-
- {series.label}
-
- ))}
+
Blocked
+
Allowed
{columns.map((column) => (
{formatTime(column.ts)}
-
{column.queries}
- {SERIES.map((series) => (
-
{column[series.key]}
- ))}
+
{formatCount(column.queries)}
+
{formatCount(column.blocked)}
+
{formatCount(column.allowed)}
))}
diff --git a/admin/src/features/overview/chartKit.tsx b/admin/src/features/overview/chartKit.tsx
index 3addda9..4b52d66 100644
--- a/admin/src/features/overview/chartKit.tsx
+++ b/admin/src/features/overview/chartKit.tsx
@@ -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,10 +13,11 @@ 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, formatCompact } 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;
@@ -102,7 +103,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({
domain: values,
@@ -113,6 +114,19 @@ export function bandScale(values: number[], plot: Plot) {
export type BandScale = ReturnType;
+/**
+ * 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({ domain: values, range: [plot.x, plot.x + plot.width] });
+}
+
+export type PointScale = ReturnType;
+
+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 +138,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 +183,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 +198,11 @@ 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 +213,10 @@ 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)",
},
+ /** Beside a point, the box is centred on the point's height rather than hung from it. */
+ tooltipBeside: {
+ transform: "translateY(-50%)",
+ },
tooltipTitle: {
fontWeight: 500,
},
@@ -201,7 +244,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 +326,7 @@ export function ChartFrame({
plot: Plot;
yScale: ValueScale;
yTicks: number[];
- xScale: BandScale;
+ xScale: TimeScale;
xTickValues: number[];
bucketSeconds: number;
}) {
@@ -298,7 +341,7 @@ export function ChartFrame({
hideAxisLine
hideTicks
tickLength={0}
- tickFormat={(value) => compact.format(Number(value))}
+ tickFormat={(value) => formatCompact(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 +363,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 +373,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 (
- 3 ? 1 : 0}
- {...stylex.props(styles.segment)}
- />
- );
+ return ;
}
+/** 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 (
onEnter(index)}
@@ -393,13 +431,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
@@ -448,20 +486,33 @@ export interface TooltipContent {
*/
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. `TooltipWithBounds`
+ * flips the box to the anchor's left when it would run past the right edge.
+ */
+ 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.
@@ -474,10 +525,10 @@ export function ChartTooltip({
key={measureKey}
left={left}
top={top}
- offsetLeft={TOOLTIP_OFFSET}
- offsetTop={TOOLTIP_OFFSET}
+ offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
+ offsetTop={beside ? 0 : CORNER_OFFSET}
style={NO_INLINE_STYLE}
- className={stylex.props(styles.tooltip).className}
+ className={stylex.props(styles.tooltip, beside && styles.tooltipBeside).className}
>
+ );
+}
diff --git a/admin/src/features/overview/clientScope.test.ts b/admin/src/features/overview/clientScope.test.ts
new file mode 100644
index 0000000..ee5af32
--- /dev/null
+++ b/admin/src/features/overview/clientScope.test.ts
@@ -0,0 +1,14 @@
+import { parseClient } from "./clientScope";
+
+test("one address passes, and nothing else does", () => {
+ expect(parseClient("192.0.2.30")).toBe("192.0.2.30");
+ expect(parseClient("2001:db8::1")).toBe("2001:db8::1");
+ expect(parseClient(undefined)).toBeUndefined();
+ expect(parseClient("")).toBeUndefined();
+ expect(parseClient(7)).toBeUndefined();
+ // A list is the Activity filter's grammar, not this page's.
+ expect(parseClient("192.0.2.30,192.0.2.31")).toBeUndefined();
+ // Longer than any address the server stores.
+ expect(parseClient("1".repeat(46))).toBeUndefined();
+ expect(parseClient("1".repeat(45))).toBe("1".repeat(45));
+});
diff --git a/admin/src/features/overview/clientScope.ts b/admin/src/features/overview/clientScope.ts
new file mode 100644
index 0000000..319186f
--- /dev/null
+++ b/admin/src/features/overview/clientScope.ts
@@ -0,0 +1,16 @@
+/**
+ * The device Overview is scoped to, as URL state beside the period.
+ *
+ * The API takes one exact address (`logger.max_client_len` bytes at most, no
+ * list), so that is what the route lets through: anything else is dropped from
+ * the URL rather than sent on to a 400. An absent scope is the whole household.
+ */
+
+/** `logger.max_client_len` on the server: the longest textual address it stores. */
+const MAX_CLIENT_LEN = 45;
+
+export function parseClient(value: unknown): string | undefined {
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_CLIENT_LEN) return undefined;
+ if (value.includes(",")) return undefined;
+ return value;
+}
diff --git a/admin/src/features/overview/overviewWindow.test.tsx b/admin/src/features/overview/overviewWindow.test.tsx
index 9c353d0..d604c0f 100644
--- a/admin/src/features/overview/overviewWindow.test.tsx
+++ b/admin/src/features/overview/overviewWindow.test.tsx
@@ -64,7 +64,7 @@ afterEach(() => vi.unstubAllGlobals());
let lastRetry: () => void;
function Probe({ period }: { period: Period }) {
- const panel = useOverviewWindow(period);
+ const panel = useOverviewWindow(period, undefined);
if (panel.status === "error") lastRetry = panel.retry;
const detail =
panel.status === "ready"
diff --git a/admin/src/features/overview/overviewWindow.ts b/admin/src/features/overview/overviewWindow.ts
index af89900..ed26210 100644
--- a/admin/src/features/overview/overviewWindow.ts
+++ b/admin/src/features/overview/overviewWindow.ts
@@ -23,8 +23,8 @@ import type { Overview, Period } from "@/lib/types";
export type Panel =
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
-export function useOverviewWindow(period: Period): Panel {
- const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
+export function useOverviewWindow(period: Period, client: string | undefined): Panel {
+ const query = useQuery({ ...overviewQuery(period, client), placeholderData: keepPreviousData });
const { refetch } = query;
const retry = useCallback(() => void refetch(), [refetch]);
diff --git a/admin/src/features/overview/seriesColors.test.ts b/admin/src/features/overview/seriesColors.test.ts
index 1c764ce..a0d581a 100644
--- a/admin/src/features/overview/seriesColors.test.ts
+++ b/admin/src/features/overview/seriesColors.test.ts
@@ -1,11 +1,44 @@
-import { OTHER_KEY, clientKey, qtypeKey, routeKey, seriesColor } from "./seriesColors";
+import { colors } from "@/ui/tokens.stylex";
+import {
+ CHART_GREEN,
+ CHART_RED,
+ OTHER_KEY,
+ clientKey,
+ clientSeriesColor,
+ qtypeKey,
+ routeKey,
+ seriesColor,
+ typeRampColor,
+} from "./seriesColors";
test("the four source-less route kinds and other are fixed, so they mean one thing everywhere", () => {
- expect(seriesColor(routeKey("blocked", null))).toBe("#ef4444");
- expect(seriesColor(routeKey("cache", null))).toBe("#059669");
- expect(seriesColor(routeKey("local", null))).toBe("#8b5cf6");
- expect(seriesColor(routeKey("rejected", null))).toBe("#f59e0b");
- expect(seriesColor(OTHER_KEY)).toBe("#71717a");
+ expect(seriesColor(routeKey("blocked", null))).toBe(CHART_RED);
+ expect(seriesColor(routeKey("cache", null))).toBe(CHART_GREEN);
+ expect(seriesColor(routeKey("local", null))).toBe(colors.seriesViolet);
+ expect(seriesColor(routeKey("rejected", null))).toBe(colors.seriesAmber);
+ expect(seriesColor(OTHER_KEY)).toBe(colors.seriesOther);
+});
+
+test("every colour is a token reference, so the charts and the CSS cannot disagree", () => {
+ for (const value of [CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY), clientSeriesColor(0), typeRampColor(0)]) {
+ expect(value).toMatch(/^var\(--/);
+ }
+});
+
+test("the client chart colours by rank: eight distinct hues, then round again", () => {
+ const first = Array.from({ length: 8 }, (_, rank) => clientSeriesColor(rank));
+ expect(new Set(first).size).toBe(8);
+ expect(clientSeriesColor(8)).toBe(clientSeriesColor(0));
+ // Never the aggregate's gray, and never the reserved red.
+ expect(first).not.toContain(seriesColor(OTHER_KEY));
+ expect(first).not.toContain(CHART_RED);
+});
+
+test("the types ring steps one hue outward and a long tail shares the lightest step", () => {
+ const steps = Array.from({ length: 6 }, (_, rank) => typeRampColor(rank));
+ expect(new Set(steps).size).toBe(6);
+ expect(typeRampColor(6)).toBe(typeRampColor(5));
+ expect(typeRampColor(40)).toBe(typeRampColor(5));
});
test("the colour of a key depends on the key and on nothing else", () => {
@@ -52,7 +85,7 @@ test("a panel of realistic entries gets a spread of hues, not one colour repeate
test("a dynamic entry never takes a fixed entry's colour", () => {
// The bug this rules out: a nameless upstream row coming out the same red as
// the Blocked slice beside it in the same ring.
- const fixedColors = new Set(["#ef4444", "#059669", "#8b5cf6", "#f59e0b", "#71717a"]);
+ const fixedColors = new Set([CHART_RED, CHART_GREEN, seriesColor(OTHER_KEY)]);
const keys = [routeKey("upstream", null), routeKey("forward_zone", "lan"), qtypeKey(28), qtypeKey(null)];
for (const key of keys) expect(fixedColors.has(seriesColor(key))).toBe(false);
});
diff --git a/admin/src/features/overview/seriesColors.ts b/admin/src/features/overview/seriesColors.ts
index f9f1fd4..b4d4003 100644
--- a/admin/src/features/overview/seriesColors.ts
+++ b/admin/src/features/overview/seriesColors.ts
@@ -1,39 +1,68 @@
/**
- * A colour per thing, not per position.
+ * Which colour a series or slice wears, as a StyleX var from `tokens.stylex.ts`.
+ * An SVG `fill` or `stroke` attribute takes a var reference as readily as CSS
+ * does (`stroke={colors.surfaceRaised}` renders `var(--…)`), so no chart holds
+ * a literal of its own and the palette lives in one place.
*
- * Every series and slice on Overview is ranked by count, and a rank that changes
- * between two thirty-second polls would recolour the whole panel if colour came
- * from the ordinal. So colour keys on the entry's semantic identity: the qtype
- * value, the client string, or — for routes — the full `(route, source)` pair,
- * because keying on the route kind alone would paint two adjacent upstream
- * slices the same and merge them into one shape.
- *
- * The four source-less route kinds and the "other" bucket are fixed rather than
- * hashed: they mean the same thing on every install, and Blocked and Cache
- * already have colours on the query-volume timeline.
+ * The routes ring keys colour on identity rather than on rank: a rank that
+ * changes between two thirty-second polls would recolour the whole panel if
+ * colour came from the ordinal, and the fixed kinds (blocked, cache, local,
+ * rejected) mean the same thing on every install. The client chart and the
+ * types ring rank instead — see their functions.
*/
import type { RouteKind } from "@/lib/types";
+import { colors } from "@/ui/tokens.stylex";
+
+export const CHART_BLUE = colors.primary;
+export const CHART_RED = colors.chartRed;
+export const CHART_GREEN = colors.chartGreen;
/**
- * The dynamic hues, validated for CVD separation and 3:1 contrast against both
- * surfaces; the same hex in light and dark, as the timeline's series are. The
- * five fixed colours below are deliberately not in here: a nameless upstream row
- * must not come out the same red as Blocked in the ring beside it.
+ * The categorical palette in rank order. The fixed colours below are
+ * deliberately not in here: a nameless upstream row must not come out the same
+ * red as Blocked in the ring beside it.
*/
-const PALETTE = ["#3b82f6", "#ec4899", "#14b8a6", "#f97316", "#6366f1", "#84cc16", "#06b6d4", "#a855f7"] as const;
+const PALETTE = [
+ colors.seriesBlue,
+ colors.seriesTeal,
+ colors.seriesViolet,
+ colors.seriesAmber,
+ colors.seriesGreen,
+ colors.seriesMagenta,
+ colors.seriesOrange,
+ colors.seriesOlive,
+] as const;
const FIXED: Record = {
- "route:blocked": "#ef4444",
- "route:cache": "#059669",
- "route:local": "#8b5cf6",
- "route:rejected": "#f59e0b",
- other: "#71717a",
+ "route:blocked": CHART_RED,
+ "route:cache": CHART_GREEN,
+ "route:local": colors.seriesViolet,
+ "route:rejected": colors.seriesAmber,
+ other: colors.seriesOther,
};
/** The identity of everything outside the top eight clients. */
export const OTHER_KEY = "other";
+/**
+ * The client chart's series colours go by rank, not by identity: the API ranks
+ * the eight busiest clients and eight hues hashed over eight identities collide
+ * almost surely, which is exactly the merged-band failure the owner rejected.
+ * A client that changes rank between polls changes colour; a legend beside the
+ * chart names every band, so the trade is legibility for stability.
+ */
+export function clientSeriesColor(rank: number): string {
+ return PALETTE[rank % PALETTE.length];
+}
+
+const TYPE_RAMP = [colors.ramp1, colors.ramp2, colors.ramp3, colors.ramp4, colors.ramp5, colors.ramp6] as const;
+
+/** The query-types ring: one hue stepped outward from the busiest type; a longer tail shares the lightest. */
+export function typeRampColor(rank: number): string {
+ return TYPE_RAMP[Math.min(rank, TYPE_RAMP.length - 1)];
+}
+
export function qtypeKey(qtype: number | null): string {
return qtype === null ? "qtype:none" : `qtype:${qtype}`;
}
@@ -62,22 +91,15 @@ function hash(key: string): number {
}
/**
- * The colour of one key, and of nothing else.
+ * The colour of one key, and of nothing else: a pure function of the identity,
+ * no panel, no key set, no rank. The routes ring needs that property because
+ * its entries churn between polls, and an assignment that read the whole set
+ * would repaint entries that did not change at all.
*
- * This is a pure function of the identity: no panel, no key set, no rank. That
- * is the property the page needs, because the panels churn — a client enters the
- * top eight and another leaves it every few polls — and an assignment that read
- * the whole set would repaint entries that did not change at all.
- *
- * The cost is that a hash is not injective: two entries of one panel can come
- * out the same hue. That is a real cost and it is the smaller one. Resolving it
- * by probing would mean the entries that lost a slot depend on which entries
- * were present, which is the churn this exists to prevent — and eight hues
- * cannot colour nine things distinctly in any case. The failure a shared hue
- * would cause instead, two neighbouring slices merging into one shape, is
- * prevented where it happens: the donut strokes every arc and the client chart
- * strokes every segment in the surface colour, so equal hues still read as two.
- * The legend and the hidden table name every entry either way.
+ * A hash is not injective, so two named upstreams can share a hue. The failure
+ * that would cause, two neighbouring slices merging into one shape, is prevented
+ * where it happens: the ring strokes every arc in the surface colour, and the
+ * legend and the hidden table name every entry either way.
*/
export function seriesColor(key: string): string {
return FIXED[key] ?? PALETTE[hash(key) % PALETTE.length];
diff --git a/admin/src/features/pause/PauseControl.tsx b/admin/src/features/pause/PauseControl.tsx
index 2e9a401..57d0aac 100644
--- a/admin/src/features/pause/PauseControl.tsx
+++ b/admin/src/features/pause/PauseControl.tsx
@@ -27,7 +27,7 @@ import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
-import { colors } from "@/ui/tokens.stylex";
+import { colors, metrics } from "@/ui/tokens.stylex";
import { useProtection } from "./protection";
const DURATIONS = [
@@ -66,7 +66,7 @@ const styles = stylex.create({
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
popover: {
width: "var(--trigger-width)",
- borderRadius: "0.25rem",
+ borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
diff --git a/admin/src/lib/CoverageNotice.tsx b/admin/src/lib/CoverageNotice.tsx
index 1818080..81d2adb 100644
--- a/admin/src/lib/CoverageNotice.tsx
+++ b/admin/src/lib/CoverageNotice.tsx
@@ -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,
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts
index 164fea7..b98046e 100644
--- a/admin/src/lib/api.ts
+++ b/admin/src/lib/api.ts
@@ -119,8 +119,9 @@ export const getQueryDetail = (id: number): Promise => 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 => 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 =>
+ request(`/api/overview${qs({ period, client })}`);
export const getLookup = (domain: string, groupId?: number): Promise =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
diff --git a/admin/src/lib/format.test.ts b/admin/src/lib/format.test.ts
index 84cffe9..dbb07e4 100644
--- a/admin/src/lib/format.test.ts
+++ b/admin/src/lib/format.test.ts
@@ -1,8 +1,18 @@
-import { formatBytes, formatClock, formatDuration, formatMicros, formatTime } from "@/lib/format";
+import {
+ formatBytes,
+ formatClock,
+ formatCompact,
+ 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(/ /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 +22,39 @@ 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("formatCompact squeezes an axis label", () => {
+ expect(formatCompact(950)).toBe("950");
+ expect(formatCompact(18432)).toBe("18K");
+ expect(formatCompact(2_500_000)).toBe("2.5M");
+});
+
+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 +65,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 +92,22 @@ 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: nothing outside this module formats a number for the
+ * reader. `datetime-local` zero-padding in `activity/datetime.ts` is a wire
+ * grammar, not a display rule, and is the one allowed `padStart`.
+ */
+test("no source file outside format.ts formats a number inline", () => {
+ const sources = import.meta.glob("../**/*.{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([]);
+});
diff --git a/admin/src/lib/format.ts b/admin/src/lib/format.ts
index b30a22f..b024f33 100644
--- a/admin/src/lib/format.ts
+++ b/admin/src/lib/format.ts
@@ -1,3 +1,13 @@
+/**
+ * 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.
+ */
+
/** 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 +28,55 @@ 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");
+const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
+
+/** A count, thousands-grouped: 18432 → "18,432". */
+export function formatCount(count: number): string {
+ return grouped.format(count);
+}
+
+/** A count squeezed for an axis label: 18432 → "18K". Never for a figure the reader acts on. */
+export function formatCompact(count: number): string {
+ return compact.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 +88,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`;
}
diff --git a/admin/src/lib/queries.ts b/admin/src/lib/queries.ts
index ddf507f..073ec23 100644
--- a/admin/src/lib/queries.ts
+++ b/admin/src/lib/queries.ts
@@ -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,
@@ -50,10 +50,10 @@ export const healthQuery = () =>
export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
-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,
});
diff --git a/admin/src/routes.tsx b/admin/src/routes.tsx
index 99b55cb..17736c2 100644
--- a/admin/src/routes.tsx
+++ b/admin/src/routes.tsx
@@ -40,6 +40,7 @@ import {
upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
+import { parseClient } from "@/features/overview/clientScope";
import { OverviewPending } from "@/features/overview/OverviewFrame";
import {
validateGroupId,
@@ -156,17 +157,21 @@ const indexRoute = createRoute({
});
/**
- * Overview. The period is the whole of its applied state, so a view of the page
- * is a link: a hand-typed or stale value falls back to the default rather than
- * reaching the API as a parameter it answers 400 to.
+ * Overview. The period and the device scope are the whole of its applied state,
+ * so a view of the page is a link: a hand-typed or stale value falls back to
+ * the default rather than reaching the API as a parameter it answers 400 to.
*/
const overviewRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/overview",
- validateSearch: (search: Record): { period?: Period } => ({
+ validateSearch: (search: Record): { period?: Period; client?: string } => ({
period: parsePeriod(search["period"]),
+ client: parseClient(search["client"]),
+ }),
+ loaderDeps: ({ search }): { period: Period; client: string | undefined } => ({
+ period: search.period ?? DEFAULT_PERIOD,
+ client: search.client,
}),
- loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
/**
* Started here, awaited nowhere. The page reads these with `useQuery` and owns
* its own loading and error surface, so awaiting would trade that contract for
@@ -177,7 +182,7 @@ const overviewRoute = createRoute({
loader: ({ context, deps }) => {
const start = (promise: Promise) => void promise.catch(() => {});
start(context.queryClient.ensureQueryData(healthQuery()));
- start(context.queryClient.ensureQueryData(overviewQuery(deps.period)));
+ start(context.queryClient.ensureQueryData(overviewQuery(deps.period, deps.client)));
// The registered names the client chart labels its series with. Started here
// so the lookup is not a second round trip after the page chunk lands.
start(context.queryClient.ensureQueryData(clientsQuery()));
@@ -402,28 +407,7 @@ const systemRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
});
-// PROTO-OVERVIEW fence start (throwaway — delete with admin/src/proto/)
-// A dev-only design-exploration route: four full-page Overview variants behind a
-// floating picker. It hangs off the root rather than the shell, so a variant is
-// judged as a page and not as the nav around it. `import.meta.env.DEV` is a
-// literal `false` in a production build, so the array folds to empty and the
-// dynamic import below is dead code Rollup drops — the proto bytes never reach
-// dist.
-const protoRoutes = import.meta.env.DEV
- ? [
- createRoute({
- getParentRoute: () => rootRoute,
- path: "/proto/overview",
- // No `validateSearch`: the picker owns `?v=` with history.replaceState,
- // and this route never navigates, so the parameter survives untouched.
- component: lazyRouteComponent(() => import("@/proto/ProtoOverview")),
- }),
- ]
- : [];
-// PROTO-OVERVIEW fence end
-
const routeTree = rootRoute.addChildren([
- ...protoRoutes,
loginRoute,
shellRoute.addChildren([
indexRoute,
diff --git a/admin/src/shell/AppShell.test.tsx b/admin/src/shell/AppShell.test.tsx
index 023bdc0..9432061 100644
--- a/admin/src/shell/AppShell.test.tsx
+++ b/admin/src/shell/AppShell.test.tsx
@@ -9,7 +9,7 @@ import { health } from "@/lib/healthFixture";
import type { ConfigStatus, Health } from "@/lib/types";
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
-const CONFIGURATION_LABELS = ["Protection", "Resolution", "System"];
+const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
/** The pages the redesign folded into the three configuration ones. */
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
@@ -110,7 +110,7 @@ test("shell renders the overview route with all nav links", async () => {
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav).toBeTruthy();
- for (const label of [...NAV_LABELS, ...CONFIGURATION_LABELS]) {
+ for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
expect(screen.getByRole("link", { name: label })).toBeTruthy();
}
for (const label of GONE_LABELS) {
@@ -127,16 +127,16 @@ test("main carries the ids the router scrolls and restores", async () => {
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
});
-test("the three configuration pages sit under a labelled group, after the rest", async () => {
+test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
- const group = screen.getByRole("list", { name: "Configuration" });
+ const group = screen.getByRole("list", { name: "System" });
expect(
within(group)
.getAllByRole("link")
.map((link) => link.textContent),
- ).toEqual(CONFIGURATION_LABELS);
+ ).toEqual(SYSTEM_LABELS);
// The group is a section of Main, not a nav of its own.
const nav = screen.getByRole("navigation", { name: "Main" });
expect(nav.contains(group)).toBe(true);
@@ -156,14 +156,14 @@ test("under file authority the nav states the file and when it was loaded", asyn
const line = await screen.findByText(/^File-managed ·/);
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
- const group = screen.getByRole("list", { name: "Configuration" });
+ const group = screen.getByRole("list", { name: "System" });
expect(group.parentElement?.contains(line)).toBe(true);
});
test("under database authority there is no authority line to read", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
- await screen.findByRole("list", { name: "Configuration" });
+ await screen.findByRole("list", { name: "System" });
expect(screen.queryByText(/File-managed/)).toBeNull();
});
@@ -253,16 +253,26 @@ test("Log out sits in the sidebar on wide, and only in the header below it", asy
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
-test("the header carries no protection display at all any more", async () => {
+test("the header carries the protection state as a reading, never as a control", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
- // Scoped to the header: "Protection" is a nav destination now, and that is
- // not the status pill this test buried.
const header = within(document.querySelector("header") as HTMLElement);
- for (const gone of [/^Protection/, /^Paused/]) {
- expect(header.queryByRole("link", { name: gone })).toBeNull();
- expect(header.queryByText(gone)).toBeNull();
- }
+ await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
+ expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
+ expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
+});
+
+test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
+ renderShell();
+ await screen.findByRole("heading", { name: "Overview" });
+ const aside = document.querySelector("aside") as HTMLElement;
+ const status = within(aside).getByLabelText("Status");
+ await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
+ // 1 second of uptime from the fixture, aged by however long the test took.
+ expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
+ // The fixture's overview body: no queries over a 24h window, stated as a rate.
+ await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
+ expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
});
test("Pause sits at the foot of the sidebar, above the version label", async () => {
diff --git a/admin/src/shell/AppShell.tsx b/admin/src/shell/AppShell.tsx
index 2ca4261..c327ced 100644
--- a/admin/src/shell/AppShell.tsx
+++ b/admin/src/shell/AppShell.tsx
@@ -9,30 +9,31 @@ import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ConfigStatusNotices from "./ConfigStatusNotices";
+import SidebarStatus, { ProtectionDot } from "./SidebarStatus";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
-import { colors, metrics } from "@/ui/tokens.stylex";
+import { colors, layers, metrics } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
-const WIDE = "@media (min-width: 768px)";
-const DARK = "@media (prefers-color-scheme: dark)";
+const WIDE = "@media (min-width: 801px)";
/**
- * The four operational surfaces, then the configuration group. Configuration
- * is a labelled group rather than a collapsible tree: three items do not earn
- * a disclosure, and a tree would hide the authority line under it.
+ * Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver
+ * is doing, SYSTEM is what it is told to do and how the box is faring. The
+ * three configuration pages sit flat in SYSTEM — "Configuration" is not one
+ * page — with the authority line under them.
*/
-const NAV_ITEMS = [
+const MONITORING_ITEMS = [
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
- { to: "/diagnostics", label: "Diagnostics" },
] as const;
-const CONFIGURATION_ITEMS = [
+const SYSTEM_ITEMS = [
{ to: "/configuration/protection", label: "Protection" },
{ to: "/configuration/resolution", label: "Resolution" },
{ to: "/configuration/system", label: "System" },
+ { to: "/diagnostics", label: "Diagnostics" },
] as const;
const styles = stylex.create({
@@ -45,7 +46,8 @@ const styles = stylex.create({
display: "flex",
alignItems: "center",
gap: "0.5rem",
- borderRadius: "0.25rem",
+ minHeight: metrics.hitTarget,
+ borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
// Carried by every item, active or not: a weight that changes on
@@ -57,16 +59,17 @@ const styles = stylex.create({
flex: 1,
},
navGroup: {
- marginTop: "1rem",
+ marginTop: "1.125rem",
},
navGroupLabel: {
+ display: "block",
paddingInline: "0.75rem",
- paddingBlock: "0.25rem",
- fontSize: "0.75rem",
+ paddingBottom: "0.375rem",
+ fontSize: "0.6875rem",
lineHeight: "1rem",
fontWeight: 600,
textTransform: "uppercase",
- letterSpacing: "0.05em",
+ letterSpacing: "0.09em",
color: colors.textMuted,
},
/**
@@ -90,12 +93,12 @@ const styles = stylex.create({
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
- paddingTop: "0.75rem",
+ paddingBlock: "0.75rem",
},
- /** The current page reads as a filled chip, heavier than the hover fill. */
+ /** The current page is a soft accent-tinted block, in the accent's own colour. */
navActive: {
- backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
- color: colors.text,
+ backgroundColor: colors.primarySurface,
+ color: colors.primaryOnSurface,
},
navIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
@@ -140,16 +143,18 @@ const styles = stylex.create({
flexDirection: { default: null, [WIDE]: "column" },
minHeight: { default: null, [WIDE]: 0 },
overflow: { default: null, [WIDE]: "hidden" },
+ backgroundColor: colors.surfaceRaised,
borderRightWidth: 1,
borderRightStyle: "solid",
borderRightColor: colors.border,
},
brand: {
paddingInline: "1rem",
- paddingBlock: "1rem",
- fontSize: "1.125rem",
- lineHeight: "1.75rem",
- fontWeight: 600,
+ paddingBlock: "1.25rem",
+ fontSize: "1rem",
+ lineHeight: "1.5rem",
+ fontWeight: 650,
+ letterSpacing: "-0.015em",
},
sidebarNav: {
flex: 1,
@@ -163,11 +168,19 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
- /** Narrow only: on WIDE the sidebar carries everything this row held. */
+ /**
+ * Narrow only: on WIDE the sidebar carries everything this row held. Sticky,
+ * so the brand, the protection state and the menu stay in reach down a long
+ * page.
+ */
header: {
display: { default: "flex", [WIDE]: "none" },
+ position: "sticky",
+ top: 0,
+ zIndex: layers.tooltip,
alignItems: "center",
gap: "0.75rem",
+ backgroundColor: colors.surfaceRaised,
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
@@ -178,9 +191,10 @@ const styles = stylex.create({
display: { default: null, [WIDE]: "none" },
},
narrowBrand: {
- fontSize: "1.125rem",
- lineHeight: "1.75rem",
- fontWeight: 600,
+ fontSize: "1rem",
+ lineHeight: "1.5rem",
+ fontWeight: 650,
+ letterSpacing: "-0.015em",
display: { default: null, [WIDE]: "none" },
},
headerRight: {
@@ -245,31 +259,37 @@ function NavItem({
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
- const groupHeadingId = useId();
+ const monitoringId = useId();
+ const systemId = useId();
return (
<>
-
- {NAV_ITEMS.map((item) => (
-
- ))}
-
-
+
{/* A span, not a heading: the sidebar label is not a section of the
page, and an h2 here lands in the middle of the page's own outline. */}
-
- Configuration
+
+ Monitoring
-
- {CONFIGURATION_ITEMS.map((item) => (
+
+ {MONITORING_ITEMS.map((item) => (
))}
+
+
+
+ System
+
+
+ {SYSTEM_ITEMS.map((item) => (
+
+ ))}
+
>
@@ -279,7 +299,8 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
- * with the version label instead of in the header of every route.
+ * with the status block and the version label instead of in the header of every
+ * route.
*/
function SidebarFooter() {
return (
@@ -287,6 +308,7 @@ function SidebarFooter() {
diff --git a/admin/src/shell/SidebarStatus.tsx b/admin/src/shell/SidebarStatus.tsx
new file mode 100644
index 0000000..c699827
--- /dev/null
+++ b/admin/src/shell/SidebarStatus.tsx
@@ -0,0 +1,159 @@
+/**
+ * The compact status block at the sidebar's foot (milestone 39): whether
+ * filtering is in force, how fast queries are arriving, and how long the
+ * process has been up. Three readings the operator glances at from any page,
+ * each from data the shell already holds — no request of its own.
+ *
+ * Queries/min derives from the Overview body the page in front of the reader
+ * is drawing, so it appears only while that page is open and its window has
+ * landed; on every other page the row is absent rather than stale.
+ */
+
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useMatch } from "@tanstack/react-router";
+import * as stylex from "@stylexjs/stylex";
+import { formatDuration, formatRate } from "@/lib/format";
+import { overviewQuery, versionQuery } from "@/lib/queries";
+import { useProtection } from "@/features/pause/protection";
+import { DEFAULT_PERIOD } from "@/features/overview/period";
+import { styles as shared } from "@/ui/styles";
+import { colors, metrics } from "@/ui/tokens.stylex";
+
+const styles = stylex.create({
+ block: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.4375rem",
+ marginInline: "1rem",
+ marginBottom: "0.75rem",
+ borderRadius: metrics.radius,
+ borderWidth: 1,
+ borderStyle: "solid",
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ paddingInline: "0.75rem",
+ paddingBlock: "0.875rem",
+ fontSize: "0.75rem",
+ lineHeight: "1rem",
+ color: colors.textMuted,
+ },
+ line: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: "0.625rem",
+ margin: 0,
+ },
+ key: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "0.4375rem",
+ },
+ value: {
+ fontWeight: 550,
+ color: colors.text,
+ },
+ dot: {
+ flexShrink: 0,
+ width: "7px",
+ height: "7px",
+ borderRadius: "999px",
+ },
+ dotActive: { backgroundColor: colors.chartGreen },
+ dotPaused: { backgroundColor: colors.warnBorderStrong },
+ dotUnavailable: { backgroundColor: colors.chartRed },
+ dotUnknown: { backgroundColor: colors.borderStrong },
+ /** The narrow header's rendering: the dot and the state alone, inline. */
+ inline: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "0.4375rem",
+ fontSize: "0.75rem",
+ lineHeight: "1rem",
+ color: colors.textSecondary,
+ },
+});
+
+const PROTECTION_TEXT = {
+ active: "Active",
+ paused: "Paused",
+ unavailable: "Unavailable",
+ unknown: "Unknown",
+} as const;
+
+const DOT_STYLE = {
+ active: styles.dotActive,
+ paused: styles.dotPaused,
+ unavailable: styles.dotUnavailable,
+ unknown: styles.dotUnknown,
+} as const;
+
+/** The dot and the word, for the narrow header where there is room for nothing more. */
+export function ProtectionDot() {
+ const { state } = useProtection();
+ return (
+
+
+ Protection {PROTECTION_TEXT[state].toLowerCase()}
+
+ );
+}
+
+/**
+ * Uptime as of now, not as of the fetch: the version answer is cached for the
+ * session, so the seconds it reported are aged by the time since it landed.
+ * Ticks every second, because the first minute reads in seconds.
+ */
+function useUptimeSeconds(): number | null {
+ const version = useQuery(versionQuery());
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const timer = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, []);
+ if (version.data === undefined) return null;
+ return version.data.uptime_seconds + Math.max(0, now - version.dataUpdatedAt) / 1000;
+}
+
+function useQueriesPerMinute(): number | null {
+ const match = useMatch({ from: "/shell/overview", shouldThrow: false });
+ const period = match?.search.period ?? DEFAULT_PERIOD;
+ // Reads the body the page fetched; never fetches one of its own. A failed
+ // refetch leaves the last body in the cache, and the page is then showing
+ // its error, not that body: the row goes with it.
+ const overview = useQuery({ ...overviewQuery(period, match?.search.client), enabled: false });
+ if (match === undefined || overview.isError || overview.data === undefined) return null;
+ if (overview.data.period !== period) return null;
+ const minutes = (overview.data.until - overview.data.since) / 60;
+ return minutes > 0 ? overview.data.totals.queries / minutes : null;
+}
+
+export default function SidebarStatus() {
+ const { state } = useProtection();
+ const rate = useQueriesPerMinute();
+ const uptime = useUptimeSeconds();
+ return (
+
+ );
+}
diff --git a/admin/src/ui/Card.tsx b/admin/src/ui/Card.tsx
new file mode 100644
index 0000000..bbff089
--- /dev/null
+++ b/admin/src/ui/Card.tsx
@@ -0,0 +1,79 @@
+/**
+ * The one panel chrome (milestone 39): a white hairline-bordered card with a
+ * prominent title and a one-line description under it. Every panel on every
+ * page is one of these, so the pages agree by construction rather than by
+ * copying a `panel` style object around.
+ *
+ * The title is an `h2` because a card is a section of the page's outline; a
+ * page that needs a different level passes `as`.
+ */
+
+import { useId } from "react";
+import * as stylex from "@stylexjs/stylex";
+import { colors, metrics } from "./tokens.stylex";
+
+export const cardStyles = stylex.create({
+ card: {
+ minWidth: 0,
+ borderRadius: metrics.radius,
+ borderWidth: 1,
+ borderStyle: "solid",
+ borderColor: colors.border,
+ backgroundColor: colors.surfaceRaised,
+ padding: metrics.cardPadding,
+ },
+ head: {
+ marginBottom: "1.25rem",
+ },
+ /**
+ * The card's title is the loudest text on the page after the stat numerals:
+ * the references (Pi-hole, NextDNS) both lead each panel with a heading the
+ * eye lands on first. Kept in one place so no heading reset outranks it.
+ */
+ title: {
+ margin: 0,
+ fontSize: "1.4rem",
+ lineHeight: 1.2,
+ fontWeight: 650,
+ letterSpacing: "-0.015em",
+ color: colors.text,
+ textWrap: "balance",
+ },
+ description: {
+ margin: 0,
+ marginTop: "0.25rem",
+ fontSize: "0.875rem",
+ lineHeight: "1.25rem",
+ color: colors.textMuted,
+ textWrap: "pretty",
+ },
+});
+
+export default function Card({
+ title,
+ description,
+ as: Heading = "h2",
+ style,
+ children,
+}: {
+ title: string;
+ /** One sentence saying what the panel shows; every card carries one (the decision record's card head). */
+ description: string;
+ as?: "h2" | "h3";
+ /** Extra styles for the card box itself: a grid placement, a clipped list variant. */
+ style?: stylex.StyleXStyles;
+ children: React.ReactNode;
+}) {
+ const titleId = useId();
+ return (
+
+
+
+ {title}
+
+
{description}
+
+ {children}
+
+ );
+}
diff --git a/admin/src/ui/ConfirmDialog.tsx b/admin/src/ui/ConfirmDialog.tsx
index b6f747e..f93d837 100644
--- a/admin/src/ui/ConfirmDialog.tsx
+++ b/admin/src/ui/ConfirmDialog.tsx
@@ -11,7 +11,7 @@
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
-import { colors, layers } from "./tokens.stylex";
+import { colors, layers, metrics } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
@@ -77,7 +77,7 @@ const styles = stylex.create({
},
dangerButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
- borderRadius: "0.25rem",
+ borderRadius: metrics.radius,
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
diff --git a/admin/src/ui/Select.tsx b/admin/src/ui/Select.tsx
index 2a56add..b8d81cf 100644
--- a/admin/src/ui/Select.tsx
+++ b/admin/src/ui/Select.tsx
@@ -22,7 +22,7 @@ import {
SelectValue,
Text,
} from "react-aria-components";
-import { colors } from "./tokens.stylex";
+import { colors, metrics } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
@@ -45,9 +45,10 @@ interface Props {
description?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
- * dialog uses, `inline` a control sitting in a row of other controls.
+ * dialog uses, `inline` a control sitting in a row of other controls, and
+ * `toolbar` the 44px hairline dropdown a page's scope picker is.
*/
- variant?: "field" | "compactField" | "inline";
+ variant?: "field" | "compactField" | "inline" | "toolbar";
/** Visible but inert, keeping its value on screen; RAC also drops it from the tab order. */
isDisabled?: boolean;
}
@@ -77,6 +78,22 @@ const styles = stylex.create({
marginTop: "0.25rem",
width: "100%",
},
+ /** Rectangular, hairline, full hit height: the decision record's toolbar selector. */
+ toolbar: {
+ minHeight: metrics.hitTarget,
+ borderRadius: metrics.radius,
+ borderWidth: 1,
+ borderStyle: "solid",
+ borderColor: { default: colors.border, ":hover": colors.borderStrong },
+ backgroundColor: colors.surfaceRaised,
+ color: colors.text,
+ paddingInline: "0.9375rem",
+ paddingBlock: "0.5rem",
+ fontSize: "0.875rem",
+ lineHeight: "1.25rem",
+ transitionProperty: "border-color",
+ transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
+ },
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
@@ -97,7 +114,7 @@ const styles = stylex.create({
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
- borderRadius: "0.25rem",
+ borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
@@ -143,7 +160,7 @@ export default function Select({
variant = "field",
isDisabled = false,
}: Props) {
- const base = variant === "field" ? shared.input : shared.smallInput;
+ const base = variant === "field" ? shared.input : variant === "toolbar" ? styles.toolbar : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
` — optional `client`, validated as an address the same way the queries filters validate theirs. Semantics: every field of the response (totals, buckets, clients, other, types, routes, coverage) describes only that client's rows.
+
+Engineering call, stated up front: the m36 projection tables aggregate across clients and cannot answer a scoped request for types/routes/totals. A scoped request therefore takes the raw-row scan path the 1h window already uses, over `idx_query_log_ts`, for every period. Household scale wears that cost; per-client projection tables would be overengineering. The web-layer response cache keys gain the client value.
+
+Scoped response shape stays identical: `clients` carries at most that one client, `other` is present and all-zero. `coverage` is unchanged. OpenAPI, generated sample, TypeScript types, deterministic handler tests (scoped vs unscoped agreement on a seeded fixture: scoped totals sum to the client's slice; an unknown client returns zeros, not 404).
+
+## Session B: tokens, format module, shared chrome
+
+- `format.ts` per the contract above, with its tests, plus the removal sweep across `admin/src`.
+- `tokens.stylex.ts`: radius token 4px; `danger`/chart red split — chart red becomes `oklch(0.65 0.19 25)` (3:1 floor: non-text and ≥24px text only; `dangerText` keeps its darker value for small text); the eight client palette colors + Other gray `oklch(0.80 0.01 260)` as named tokens (values in `ui-visual-redesign.md`); card metrics.
+- Card chrome: every panel becomes a white hairline-bordered card, 4px radius, with a title (1.4rem, weight 650, ink) and a one-line muted description. Selector specificity note from the decision record applies.
+- Sidebar: MONITORING (Overview, Activity, Clients) / SYSTEM (Configuration, Diagnostics) group labels; foot status block reading the existing `/api/health` protection condition (Active/Paused dot + blocking state) and `/api/version` `uptime_seconds` (formatted per contract). Queries/min derives from the current Overview response (`totals.queries / window minutes`, rate format) and renders only where that response is live; other pages show the block without the rate row. Mobile drawer keeps the same content.
+- Toolbar selector styles (rectangular, radius token, 44px), ranked-list styles (full-bleed, red rule variant, no hover), progress-bar card style.
+
+## Session C: Overview page
+
+- Toolbar: client selector (from the clients list; "All devices" default) + period selector. Client is URL state beside period: `/overview?period=24h&client=…`, validated in the route search; the picker navigates.
+- Stat tiles restyled per the decision record (centered 2.5rem numerals, semantic color, lowercase captions). Percentage tile uses the two-decimal contract.
+- `chartKit.tsx` rework: smooth area chart (blue and red line+fill pairs, edge-to-edge x mapping, point hover with dots and guide line, side-flipping tooltip that never covers the hovered bucket, SVG `hidden` handled via attributes); stacked bars keep band hover, gain the 0.5-unit anti-seam bleed; palette tokens drive series colors, Other keys to the gray token; donut legend restyle.
+- Cache/progress presentation and the mobile ≤800px stack (top-bar shell, single column, merged stat card) per the decision record.
+- Tests: route search validation for `client`, chart geometry units where practical, axe pass, existing page tests updated.
+
+## Session D: sweep
+
+Activity, Clients, Configuration, Diagnostics adopt the tokens, card chrome, list styles and format module. No layout redesign of those pages in this milestone — chrome and formatting only. Byte budget check; screenshots of every page on the smoke server for owner acceptance before commit.
+
+## Acceptance
+
+- `/api/overview` scoped and unscoped agree on seeded fixtures; contract artifacts regenerated.
+- No numeric formatting outside `format.ts`; the pinned format tests pass.
+- Overview matches the decision record at 1440 and 420 widths (screenshot review).
+- All existing suites green; bundle within budget; no new dependency.
diff --git a/specs/ui-visual-redesign.md b/specs/ui-visual-redesign.md
new file mode 100644
index 0000000..c711b8f
--- /dev/null
+++ b/specs/ui-visual-redesign.md
@@ -0,0 +1,35 @@
+# UI visual redesign — decision record
+
+Decided 2026-09-03 from the throwaway prototype at `scratchpad/proto-overview/index.html` (session-local; the screenshots and this record are what survive). This settles the visual language only; the IA, routes and API are `ui-redesign.md`'s and stay as built.
+
+## Direction
+
+Pi-hole's structure × NextDNS's visual language, on the light theme. The owner supplied both references directly; four earlier exploration rounds (below) were rejected.
+
+## Chosen values
+
+- **Ground and cards.** Near-white ground; white cards with hairline borders; radius **4px**; card padding ~28px; density 1.00; no panel header bars.
+- **Card head.** Every card: a 1.4rem weight-650 ink title plus a one-line muted description sentence (0.875rem). The title selector must outrank any heading reset (a `.v h2` reset silently ate the title styles in the prototype).
+- **Stat tiles.** Four tiles: one centered 2.5rem regular-weight numeral over a lowercase muted caption. Semantic color on the numeral itself: ink for totals, red for blocked, muted gray for the percentage, ink for clients. No icons, no borders inside.
+- **Toolbar.** Two rectangular dropdown selectors (radius token, 44px min-height, hairline border): client scope left, period right. Not pills — the owner rejected the pill shape.
+- **Queries chart.** Smooth Catmull-Rom area. Total: 2px blue line + 13% blue fill. Blocked: 2px red line + 35% red fill — both series are line-plus-backdrop pairs. Points spread **edge to edge**: the first bucket sits on the left plot edge, the last on the right; the curve never extends past its own data. Hover: NextDNS-style point hover — a 4px dot on each series (white 2px stroke) plus a thin vertical guide line; no bucket-band highlight. Tooltip sits ~16px **beside** the point, flipping to the left near the right edge; it never covers the hovered bucket. SVG note: `hidden` is attribute-only on SVG elements — toggle with `setAttribute`/`removeAttribute`, never the `.hidden` property.
+- **Client activity chart.** Stacked bars, band-centered, band hover highlight kept. Segments bleed 0.5 viewBox units into the segment below so antialiasing cannot open white seams. Series: the top **eight** named clients (the `/api/overview` contract's own count) plus one aggregated flat "Other" band (gray `oklch(0.80 0.01 260)`) on top. Legend lists the nine names + Other; the tooltip ranks the top four for the bucket, then an Other row, then the total.
+- **Client palette** (categorical, reserved red at hue 25 excluded): blue `oklch(0.60 0.14 258)`, teal `oklch(0.68 0.12 190)`, violet `oklch(0.62 0.15 300)`, amber `oklch(0.75 0.13 80)`, green `oklch(0.65 0.13 150)`, magenta `oklch(0.66 0.14 340)`, orange `oklch(0.70 0.14 55)`, olive `oklch(0.72 0.11 120)`, sky `oklch(0.78 0.09 230)`. Known-closest pair: amber/orange.
+- **Red.** `oklch(0.65 0.19 25)` (#ED5350, 3.54:1 vs white). Compliant for non-text UI and large text (3:1 floor); it must never color small text.
+- **Accent.** Blue, hue 258, for nav, links and the total-queries series. Green (hue 150) stays the cache/success color. Green and red are fixed semantics, never derived from the accent hue.
+- **Ranked lists.** Full-bleed rows, hairline dividers, name left with a muted sub-line (list/IP), right-aligned tabular count. Blocked list: a 3px red rule flush on each row's left edge. **No hover effect** — the rows are not interactive.
+- **Progress cards.** Cache hit rate as a full-width rounded bar with a colored percentage figure under it (NextDNS's Encrypted DNS pattern).
+- **Query types.** Donut + legend list with counts and percentages, single-hue lightness/chroma ramp off the accent.
+- **Sidebar.** Grouped section labels (MONITORING / SYSTEM), the active item as a soft accent-tinted block, and a compact status block at the foot: Active dot + blocking state, queries/min, uptime.
+- **Mobile (≤800px).** The sidebar becomes a sticky top bar (brand + status pinned, nav scrolls horizontally); everything stacks single-column; the stat tiles merge into one card — numbers side by side, the percentage on a line under.
+- **Type.** System font stack. No webfont.
+
+## Rejected along the way
+
+- **Round 1** (Quiet / Console / Instrument / Signal): dark themes rejected outright; Console's density liked, its darkness not.
+- **Round 2** (Ledger / Paper / Grid): a Ledger+Grid mix was "disliked least", never liked.
+- **Round 3** (convergence with toggles): the settled config (panels frame, system labels, blue accent, 3px radius) read as competent but had no signature — "not an immediate this-is-it".
+- **Round 4** (signatures: IBM Plex type-led, a blocked-share hero Meter, a chart-first Geist): all three rejected; the direct Pi-hole × NextDNS reference replaced them.
+- **Chart details rejected in the blend round:** pill-shaped selectors; bucket-band hover on the area chart; tooltip centered above the point (covers the data); a solid 0.9-opacity red band (one-color, inconsistent with the blue pair); the curve overhanging the last bucket.
+- **Client colors rejected:** 15 hues at 24° spacing (rainbow, neighbors indistinguishable); 6 colors + 9 graduated grays (the gray stripes merge into a dull cap). Codex concurred: nine tiny series cannot be made simultaneously legible through color; aggregate the tail.
+- **Not adopted from Codex's option list:** hue families with lightness steps (15 still exceed discrimination), small multiples (a different card, not a fix), hover isolation (owner declined), top-N-per-bucket recoloring and streamgraphs (both distort trend reading).
diff --git a/src/storage/repositories/queries_repo.zig b/src/storage/repositories/queries_repo.zig
index a76f3b0..0b43b2d 100644
--- a/src/storage/repositories/queries_repo.zig
+++ b/src/storage/repositories/queries_repo.zig
@@ -1240,6 +1240,21 @@ const overview_raw_sql =
\\ WHERE timestamp >= ?1 AND timestamp < ?2
;
+/// `INDEXED BY` because the planner, offered both indexes, walks
+/// `idx_query_log_client` — the device's whole retained history — and filters
+/// the window out of it afterwards. The window is the small side: a device is
+/// a slice of every row ever kept, and the window is a slice of hours.
+const overview_raw_client_sql =
+ \\SELECT timestamp, client_ip, qtype, blocked, cache_hit, response_time_us,
+ \\ route_kind,
+ \\ CASE route_kind
+ \\ WHEN 'upstream' THEN upstream
+ \\ WHEN 'forward_zone' THEN forward_zone
+ \\ END AS source
+ \\ FROM query_log INDEXED BY idx_query_log_ts
+ \\ WHERE timestamp >= ?1 AND timestamp < ?2 AND client_ip = ?3
+;
+
/// The whole Overview payload for `[since, since + bucket_seconds *
/// bucket_count)`, from one already-open read transaction — the caller owns the
/// transaction and the lock, as it does for every other read here.
@@ -1250,6 +1265,13 @@ const overview_raw_sql =
/// buckets) one pass over the raw rows in the window does the same work in Zig.
/// Both produce the same contracts, which is what the equivalence test asserts.
///
+/// A `client` scopes the whole payload to that address's rows (milestone 39).
+/// The projections aggregate across clients for everything but the per-client
+/// series, so a scoped read always takes the raw path, whatever the width: one
+/// scan of the window's rows over `idx_query_log_ts`, which household volume
+/// wears. Per-client projections would be a second copy of every table for one
+/// filter.
+///
/// Preconditions are caller bugs, not runtime conditions: a zero width or count
/// is `error.Misuse`, and so is a projection-path window that is not on the
/// grid, because the projections cannot express it. The handler's `window()`
@@ -1260,19 +1282,20 @@ pub fn overview(
since: i64,
bucket_seconds: u32,
bucket_count: u32,
+ client: ?[]const u8,
) db.Error!Overview {
if (bucket_seconds == 0 or bucket_count == 0) return error.Misuse;
const width: i64 = bucket_seconds;
const span = std.math.mul(i64, width, bucket_count) catch return error.Misuse;
const until = std.math.add(i64, since, span) catch return error.Misuse;
- const from_projections = width >= grain;
+ const from_projections = client == null and width >= grain;
if (from_projections and (@mod(since, grain) != 0 or @rem(width, grain) != 0)) return error.Misuse;
var accum = try Accum.init(arena, since, width, bucket_count);
if (from_projections) {
try readProjections(database, &accum, since, until);
} else {
- try readRaw(database, &accum, since, until);
+ try readRaw(database, &accum, since, until, client);
}
return try accum.finish();
}
@@ -1327,11 +1350,12 @@ fn readProjections(database: *db.Db, accum: *Accum, since: i64, until: i64) db.E
}
}
-fn readRaw(database: *db.Db, accum: *Accum, since: i64, until: i64) db.Error!void {
- var stmt = try database.prepare(overview_raw_sql);
+fn readRaw(database: *db.Db, accum: *Accum, since: i64, until: i64, client: ?[]const u8) db.Error!void {
+ var stmt = try database.prepare(if (client == null) overview_raw_sql else overview_raw_client_sql);
defer stmt.deinit();
try stmt.bindInt(1, since);
try stmt.bindInt(2, until);
+ if (client) |address| try stmt.bindText(3, address);
while (try stmt.step()) {
const index = try accum.indexOf(stmt.columnInt(0));
@@ -1371,6 +1395,21 @@ fn openLog() !db.Db {
return database;
}
+test "a scoped overview read walks the timestamp index, not the client's history" {
+ var database = try openLog();
+ defer database.close();
+
+ var stmt = try database.prepare("EXPLAIN QUERY PLAN " ++ overview_raw_client_sql);
+ defer stmt.deinit();
+ try stmt.bindInt(1, 0);
+ try stmt.bindInt(2, 1);
+ try stmt.bindText(3, "192.0.2.10");
+ try testing.expect(try stmt.step());
+ const detail = stmt.columnText(3);
+ try testing.expect(std.mem.indexOf(u8, detail, "idx_query_log_ts") != null);
+ try testing.expect(std.mem.indexOf(u8, detail, "idx_query_log_client") == null);
+}
+
fn plainRow(timestamp: i64, domain: []const u8) Row {
return .{
.timestamp = timestamp,
@@ -2309,7 +2348,7 @@ test "the type breakdown groups by qtype, keeps the null row and orders it last"
aggRow(-1, "192.0.2.10", 255, .upstream, "9.9.9.9"),
});
- const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).types;
+ const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).types;
try testing.expectEqual(@as(usize, 4), rows.len);
try testing.expectEqual(@as(?u16, 1), rows[0].qtype);
try testing.expectEqual(@as(u64, 3), rows[0].count);
@@ -2325,7 +2364,7 @@ test "an empty window has no type rows at all" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
- const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).types;
+ const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).types;
try testing.expectEqual(@as(usize, 0), rows.len);
}
@@ -2349,7 +2388,7 @@ test "the route breakdown keys on the answering resolver, not on blocklist prove
aggRow(8, "192.0.2.10", 1, .rejected, null),
});
- const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).routes;
+ const rows = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).routes;
// Two upstreams, one null-source upstream, one forward zone and four
// source-less kinds. Every row carries the same `source_name`, so a
// breakdown that grouped by it would collapse to one row.
@@ -2381,7 +2420,7 @@ test "an empty window has no route rows at all" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
- const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).routes;
+ const rows = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).routes;
try testing.expectEqual(@as(usize, 0), rows.len);
}
@@ -2391,7 +2430,7 @@ test "an empty window still has a zero-filled other series and no named clients"
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
- const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
+ const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
try testing.expectEqual(@as(usize, 0), result.clients.len);
try testing.expectEqual(@as(usize, agg_buckets), result.other.len);
for (result.other) |count| try testing.expectEqual(@as(u64, 0), count);
@@ -2413,7 +2452,7 @@ test "client series are bucket-aligned, zero-filled and ranked by in-window tota
aggRow(agg_width * agg_buckets, "192.0.2.10", 1, .upstream, "9.9.9.9"),
});
- const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
+ const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
try testing.expectEqual(@as(usize, 2), result.clients.len);
try testing.expectEqualStrings("192.0.2.20", result.clients[0].client);
try testing.expectEqualStrings("192.0.2.10", result.clients[1].client);
@@ -2445,7 +2484,7 @@ test "the ninth client folds into other and the cut is the same on every read" {
}
}
- const result = (try overview(&database, arena, agg_since, agg_width, agg_buckets)).clients;
+ const result = (try overview(&database, arena, agg_since, agg_width, agg_buckets, null)).clients;
try testing.expectEqual(@as(usize, max_client_series), result.clients.len);
// The busiest is 192.0.2.108 with nine rows; the lone folded client is
// 192.0.2.100 with one.
@@ -2484,7 +2523,7 @@ test "the three breakdowns conserve the window's total" {
)});
}
- const result = try overview(&database, arena, agg_since, agg_width, agg_buckets);
+ const result = try overview(&database, arena, agg_since, agg_width, agg_buckets, null);
try testing.expect(result.totals.queries > 0);
var typed: u64 = 0;
@@ -2518,7 +2557,7 @@ test "the aggregations pass a redacted client through as the log stored it" {
aggRow(1, logger.hidden_marker, 1, .upstream, "9.9.9.9"),
});
- const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets)).clients;
+ const result = (try overview(&database, arena_state.allocator(), agg_since, agg_width, agg_buckets, null)).clients;
try testing.expectEqual(@as(usize, 1), result.clients.len);
try testing.expectEqualStrings(logger.hidden_marker, result.clients[0].client);
try testing.expectEqual(@as(u64, 2), result.clients[0].buckets[0]);
@@ -2556,7 +2595,7 @@ test "a prune committed mid-read is invisible to the reader's transaction" {
const arena = arena_state.allocator();
var tx = try db.ReadTx.begin(&reader);
- const before = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
+ const before = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
const watermark_before = try availableSince(&reader);
try testing.expectEqual(@as(u64, 3), before.queries);
@@ -2567,13 +2606,13 @@ test "a prune committed mid-read is invisible to the reader's transaction" {
// Neither half of the answer moved: the rows the reader would report and
// the watermark it would tag them with still describe one state.
- const during = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
+ const during = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
try testing.expectEqual(before.queries, during.queries);
try testing.expectEqual(watermark_before, try availableSince(&reader));
try tx.commit();
// The next response sees the prune — both halves of it.
- const after = (try overview(&reader, arena, agg_since, 1000, 1)).totals;
+ const after = (try overview(&reader, arena, agg_since, 1000, 1, null)).totals;
try testing.expectEqual(@as(u64, 1), after.queries);
try testing.expectEqual(pruned.available_since, try availableSince(&reader));
}
@@ -3127,7 +3166,7 @@ fn expectOverviewMatchesOracle(database: *db.Db, since: i64) !void {
for (overview_windows) |window| {
const want = try oracle.build(database, arena, since, window.bucket_seconds, window.bucket_count);
- const got = try overview(database, arena, since, window.bucket_seconds, window.bucket_count);
+ const got = try overview(database, arena, since, window.bucket_seconds, window.bucket_count, null);
expectOverviewEqual(want, got) catch |err| {
std.debug.print("overview mismatch at bucket_seconds={d}\n", .{window.bucket_seconds});
return err;
@@ -3239,16 +3278,16 @@ test "overview refuses a window the projections cannot express" {
const since = bucketOf(agg_since);
- try testing.expectError(error.Misuse, overview(&database, arena, since, 0, 48));
- try testing.expectError(error.Misuse, overview(&database, arena, since, 1800, 0));
- try testing.expectError(error.Misuse, overview(&database, arena, std.math.maxInt(i64) - 1, 3600, 48));
+ try testing.expectError(error.Misuse, overview(&database, arena, since, 0, 48, null));
+ try testing.expectError(error.Misuse, overview(&database, arena, since, 1800, 0, null));
+ try testing.expectError(error.Misuse, overview(&database, arena, std.math.maxInt(i64) - 1, 3600, 48, null));
// On the projection path the grid is part of the contract: an unaligned
// start or a width that is not a whole number of grains cannot be answered
// from 30-minute rows, and guessing would be worse than refusing.
- try testing.expectError(error.Misuse, overview(&database, arena, since + 1, 1800, 48));
- try testing.expectError(error.Misuse, overview(&database, arena, since, 2700, 48));
+ try testing.expectError(error.Misuse, overview(&database, arena, since + 1, 1800, 48, null));
+ try testing.expectError(error.Misuse, overview(&database, arena, since, 2700, 48, null));
// Below the grain none of that applies: the raw rows carry every second.
- _ = try overview(&database, arena, since + 1, 60, 60);
+ _ = try overview(&database, arena, since + 1, 60, 60, null);
}
diff --git a/src/web/handlers/overview.zig b/src/web/handlers/overview.zig
index 736cabd..f7715a2 100644
--- a/src/web/handlers/overview.zig
+++ b/src/web/handlers/overview.zig
@@ -31,6 +31,7 @@ const std = @import("std");
const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
+const logger = @import("../../storage/logger.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
const server = @import("../server.zig");
@@ -139,10 +140,12 @@ pub fn handle(
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
+ var client_buf: [max_client_value_len]u8 = undefined;
+ const client = clientParam(request.query, &client_buf) catch return badClient(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
- const body = cachedBody(state, io, database, request.arena, period, span) catch |err| {
+ const body = cachedBody(state, io, database, request.arena, period, span, client) catch |err| {
return internal(request, err);
};
return http_util.respondBytes(request, .ok, body, http_util.content_type_json, &.{});
@@ -165,6 +168,7 @@ fn cachedBody(
arena: std.mem.Allocator,
period: Period,
span: Window,
+ client: ?[]const u8,
) db.Error![]const u8 {
state.querylog_lock.lockUncancelable(io);
defer state.querylog_lock.unlock(io);
@@ -172,16 +176,16 @@ fn cachedBody(
const index = @intFromEnum(period);
const data_version = try database.queryInt("PRAGMA data_version");
- if (state.overview_cache.get(index, span.until, data_version)) |cached| {
+ if (state.overview_cache.get(index, span.until, data_version, client)) |cached| {
// The copy is what makes a later rebuild's free-and-replace safe: the
// response is written after the lock is gone, and by then these bytes
// may belong to nobody.
return arena.dupe(u8, cached);
}
- const body = try buildBody(state, io, database, arena, period, span);
+ const body = try buildBody(state, io, database, arena, period, span, client);
const owned = try state.gpa.dupe(u8, body);
- state.overview_cache.put(state.gpa, index, span.until, data_version, owned);
+ try state.overview_cache.put(state.gpa, index, span.until, data_version, client, owned);
return body;
}
@@ -194,6 +198,7 @@ fn buildBody(
arena: std.mem.Allocator,
period: Period,
span: Window,
+ client: ?[]const u8,
) db.Error![]const u8 {
var scope = try server.QuerylogRead.openLocked(state, io, database);
errdefer scope.abort();
@@ -203,6 +208,7 @@ fn buildBody(
span.since,
span.bucket_seconds,
span.bucket_count,
+ client,
);
const window_coverage = try coverage.read(database, span.since);
try scope.commit();
@@ -245,6 +251,29 @@ fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
}
+pub const ClientError = error{BadClient};
+
+/// `queryValue` measures the value before it decodes it, and a browser
+/// percent-encodes every colon of an IPv6 address: three bytes on the wire
+/// per byte of address.
+const max_client_value_len = 3 * logger.max_client_len;
+
+/// One exact client address, or nothing. An absent or empty value is the
+/// whole household, as the query-log filter reads it; the length bound is that
+/// filter's too. A comma is a list, and the overview scopes to one device.
+fn clientParam(query: []const u8, buf: *[max_client_value_len]u8) ClientError!?[]const u8 {
+ const found = http_util.queryValue(query, "client", buf) catch return error.BadClient;
+ const text = found orelse return null;
+ if (text.len == 0) return null;
+ if (text.len > logger.max_client_len) return error.BadClient;
+ if (std.mem.indexOfScalar(u8, text, ',') != null) return error.BadClient;
+ return text;
+}
+
+fn badClient(request: *http_util.Request) http_util.HandlerError!void {
+ return http_util.respondError(request, .bad_request, "client must be a single client address");
+}
+
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .service_unavailable, "query log unavailable");
}
@@ -284,6 +313,23 @@ test "an absent period defaults and a bad one is rejected" {
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
}
+test "an absent client is the whole household and a bad one is rejected" {
+ var buf: [max_client_value_len]u8 = undefined;
+ try testing.expectEqual(@as(?[]const u8, null), try clientParam("period=1h", &buf));
+ try testing.expectEqualStrings("192.0.2.10", (try clientParam("client=192.0.2.10", &buf)).?);
+ try testing.expectEqual(@as(?[]const u8, null), try clientParam("client=", &buf));
+ try testing.expectError(error.BadClient, clientParam("client=192.0.2.10,192.0.2.11", &buf));
+ try testing.expectError(error.BadClient, clientParam("client=%2", &buf));
+ // A browser encodes the colons; the decoded address is what the bound
+ // applies to.
+ try testing.expectEqualStrings(
+ "2001:db8:85a3:8d3:1319:8a2e:370:7348",
+ (try clientParam("client=2001%3Adb8%3A85a3%3A8d3%3A1319%3A8a2e%3A370%3A7348", &buf)).?,
+ );
+ // Longer than any address: rejected rather than truncated.
+ try testing.expectError(error.BadClient, clientParam("client=" ++ "1" ** 46, &buf));
+}
+
test "each period spans its own bucket width times its count" {
for (std.enums.values(Period)) |period| {
const span = window(period, 1_700_000_000);
@@ -391,6 +437,7 @@ test "one overview answers totals and buckets that agree over the same window" {
span.since,
span.bucket_seconds,
span.bucket_count,
+ null,
);
try testing.expectEqual(@as(u64, 3), data.totals.queries);
@@ -428,6 +475,7 @@ test "an empty window reports zeros with a null mean" {
span.since,
span.bucket_seconds,
span.bucket_count,
+ null,
);
try testing.expectEqual(@as(u64, 0), data.totals.queries);
@@ -444,20 +492,37 @@ test "the cache serves one period's bytes and rebuilds when the key moves" {
var cache: server.OverviewCache = .{};
defer cache.deinit(gpa);
- try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7));
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, null));
- cache.put(gpa, 0, 100, 7, try gpa.dupe(u8, "first"));
- try testing.expectEqualStrings("first", cache.get(0, 100, 7).?);
+ try cache.put(gpa, 0, 100, 7, null, try gpa.dupe(u8, "first"));
+ try testing.expectEqualStrings("first", cache.get(0, 100, 7, null).?);
// A different period, a rolled window and a bumped data version are three
// different keys, and none of them hits.
- try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7));
- try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7));
- try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8));
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(1, 100, 7, null));
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 101, 7, null));
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 8, null));
// A rebuild replaces the entry and frees the old body; the leak checker in
// `testing.allocator` is the assertion.
- cache.put(gpa, 0, 100, 8, try gpa.dupe(u8, "second"));
- try testing.expectEqualStrings("second", cache.get(0, 100, 8).?);
+ try cache.put(gpa, 0, 100, 8, null, try gpa.dupe(u8, "second"));
+ try testing.expectEqualStrings("second", cache.get(0, 100, 8, null).?);
+}
+
+test "a scoped body has its own slot, keyed on the client too" {
+ const gpa = testing.allocator;
+ var cache: server.OverviewCache = .{};
+ defer cache.deinit(gpa);
+
+ try cache.put(gpa, 0, 100, 7, null, try gpa.dupe(u8, "household"));
+ try cache.put(gpa, 0, 100, 7, "192.0.2.10", try gpa.dupe(u8, "one device"));
+ // Neither evicts the other.
+ try testing.expectEqualStrings("household", cache.get(0, 100, 7, null).?);
+ try testing.expectEqualStrings("one device", cache.get(0, 100, 7, "192.0.2.10").?);
+ // Another client is a miss, and its rebuild takes the scoped slot over.
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, "192.0.2.11"));
+ try cache.put(gpa, 0, 100, 7, "192.0.2.11", try gpa.dupe(u8, "another"));
+ try testing.expectEqual(@as(?[]const u8, null), cache.get(0, 100, 7, "192.0.2.10"));
+ try testing.expectEqualStrings("household", cache.get(0, 100, 7, null).?);
}
/// Two connections onto one file, which is the only arrangement in which
@@ -528,6 +593,7 @@ const CacheFixture = struct {
self.arena_state.allocator(),
period,
span,
+ null,
);
}
diff --git a/src/web/openapi.yaml b/src/web/openapi.yaml
index 1baa69b..58bca5d 100644
--- a/src/web/openapi.yaml
+++ b/src/web/openapi.yaml
@@ -438,8 +438,18 @@ paths:
Buckets are UTC-aligned and zero-filled: 1h into 60 one-minute buckets,
24h into 48 half-hour buckets, 7d into 168 one-hour buckets, 30d into
120 six-hour buckets. The last bucket is the one in progress.
+
+ With `client`, every field describes that one device's rows: `clients`
+ carries at most that device and `other` is present and all zero. A
+ device the log never saw answers zeros, not 404.
parameters:
- $ref: "#/components/parameters/Period"
+ - name: client
+ in: query
+ description: Scope every figure to one exact client address. Absent or empty is the whole household.
+ schema:
+ type: string
+ maxLength: 45
responses:
"200":
description: The period's overview.
diff --git a/src/web/server.zig b/src/web/server.zig
index bae1e19..f9377a7 100644
--- a/src/web/server.zig
+++ b/src/web/server.zig
@@ -199,38 +199,65 @@ pub const OverviewCache = struct {
body: []u8 = &.{},
until: i64 = 0,
data_version: i64 = 0,
+ /// The client the body is scoped to; empty for the whole household.
+ client: []u8 = &.{},
};
slots: [slot_count]Slot = @splat(.{}),
+ /// One scoped body per period, beside the household one: a device pick
+ /// must not evict the household body the next unscoped request wants.
+ /// Only the latest client per period is kept — the picker moves one
+ /// device at a time.
+ scoped: [slot_count]Slot = @splat(.{}),
/// The stored bytes for this key, or null. The caller copies them into its
/// request arena before releasing the lock: a later rebuild frees this
/// allocation.
- pub fn get(self: *const OverviewCache, period_index: usize, until: i64, data_version: i64) ?[]const u8 {
- const slot = &self.slots[period_index];
+ pub fn get(
+ self: *const OverviewCache,
+ period_index: usize,
+ until: i64,
+ data_version: i64,
+ client: ?[]const u8,
+ ) ?[]const u8 {
+ const slot = if (client == null) &self.slots[period_index] else &self.scoped[period_index];
if (slot.body.len == 0) return null;
if (slot.until != until or slot.data_version != data_version) return null;
+ if (!std.mem.eql(u8, slot.client, client orelse "")) return null;
return slot.body;
}
/// Takes ownership of `body`, which must be a `gpa` allocation, and frees
- /// whatever the slot held.
+ /// whatever the slot held. On allocation failure the body is freed and the
+ /// slot keeps its previous entry.
pub fn put(
self: *OverviewCache,
gpa: Allocator,
period_index: usize,
until: i64,
data_version: i64,
+ client: ?[]const u8,
body: []u8,
- ) void {
- const slot = &self.slots[period_index];
+ ) Allocator.Error!void {
+ const owned_client = gpa.dupe(u8, client orelse "") catch |err| {
+ gpa.free(body);
+ return err;
+ };
+ const slot = if (client == null) &self.slots[period_index] else &self.scoped[period_index];
gpa.free(slot.body);
- slot.* = .{ .body = body, .until = until, .data_version = data_version };
+ gpa.free(slot.client);
+ slot.* = .{ .body = body, .until = until, .data_version = data_version, .client = owned_client };
}
pub fn deinit(self: *OverviewCache, gpa: Allocator) void {
for (&self.slots) |*slot| {
gpa.free(slot.body);
+ gpa.free(slot.client);
+ slot.* = .{};
+ }
+ for (&self.scoped) |*slot| {
+ gpa.free(slot.body);
+ gpa.free(slot.client);
slot.* = .{};
}
}
diff --git a/src/web/web_integration_test.zig b/src/web/web_integration_test.zig
index 4e1568c..2f0e395 100644
--- a/src/web/web_integration_test.zig
+++ b/src/web/web_integration_test.zig
@@ -2810,6 +2810,94 @@ test "W10 milestone 30: the three breakdowns conserve the totals over one window
try bounded(env.io(), default_budget, populatedAggregations, .{ env.io(), env });
}
+/// Milestone 39: `client` narrows every figure to one device, over the same
+/// seeded matrix `populatedAggregations` reads unscoped.
+fn scopedAggregations(io: std.Io, env: *Env) anyerror!void {
+ var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var conn: Conn = undefined;
+ try conn.connect(io, env.addr);
+ defer conn.close(io);
+
+ var body_buf: [256 * 1024]u8 = undefined;
+
+ const household = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h", &body_buf);
+
+ // The three device slices sum to the household on every figure a tile or
+ // a breakdown draws, so scoped and unscoped describe the same rows.
+ var sliced_queries: u64 = 0;
+ var sliced_blocked: u64 = 0;
+ var sliced_typed: u64 = 0;
+ var sliced_routed: u64 = 0;
+ for ([_][]const u8{ "192.0.2.30", "192.0.2.31", "192.0.2.32" }) |device| {
+ const path = try std.fmt.allocPrint(arena, "/api/overview?period=1h&client={s}", .{device});
+ const body = try getJson(handlers_overview.Body, arena, &conn, path, &body_buf);
+ sliced_queries += body.totals.queries;
+ sliced_blocked += body.totals.blocked;
+ try testing.expectEqual(@as(u64, 1), body.totals.clients);
+ for (body.types) |row| sliced_typed += row.count;
+ for (body.routes) |row| sliced_routed += row.count;
+
+ try testing.expectEqual(@as(usize, 1), body.clients.len);
+ try testing.expectEqualStrings(device, body.clients[0].client);
+ try testing.expectEqual(body.buckets.len, body.other.len);
+ var bucketed: u64 = 0;
+ for (body.buckets, 0..) |bucket, at| {
+ bucketed += bucket.queries;
+ try testing.expectEqual(@as(u64, 0), body.other[at]);
+ try testing.expectEqual(bucket.queries, body.clients[0].buckets[at]);
+ }
+ try testing.expectEqual(body.totals.queries, bucketed);
+ try testing.expectEqualStrings("1h", body.period);
+ try testing.expectEqual(household.coverage.available_since, body.coverage.available_since);
+ }
+ try testing.expectEqual(household.totals.queries, sliced_queries);
+ try testing.expectEqual(household.totals.blocked, sliced_blocked);
+ try testing.expectEqual(household.totals.queries, sliced_typed);
+ try testing.expectEqual(household.totals.queries, sliced_routed);
+
+ // The exact slices, pinned: the seed writes four rows for .30 and three
+ // for .31 with one of them blocked. The wide period takes the raw path
+ // too, and reads the same rows.
+ const thirty = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h&client=192.0.2.30", &body_buf);
+ try testing.expectEqual(@as(u64, 4), thirty.totals.queries);
+ try testing.expectEqual(@as(u64, 0), thirty.totals.blocked);
+ const thirty_one = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=30d&client=192.0.2.31", &body_buf);
+ try testing.expectEqual(@as(u64, 3), thirty_one.totals.queries);
+ try testing.expectEqual(@as(u64, 1), thirty_one.totals.blocked);
+ try testing.expectEqual(@as(usize, 120), thirty_one.buckets.len);
+
+ // A device the log never saw is zeros with the full shape, not a 404: the
+ // picker may still name a client whose rows retention has since pruned.
+ const unknown = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h&client=192.0.2.99", &body_buf);
+ try testing.expectEqual(@as(u64, 0), unknown.totals.queries);
+ try testing.expectEqual(@as(u64, 0), unknown.totals.clients);
+ try testing.expectEqual(@as(usize, 0), unknown.clients.len);
+ try testing.expectEqual(@as(usize, 60), unknown.other.len);
+ for (unknown.other) |count| try testing.expectEqual(@as(u64, 0), count);
+
+ // An empty value is the household, like the query-log filter; a list is not
+ // a device.
+ const empty = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h&client=", &body_buf);
+ try testing.expectEqual(household.totals.queries, empty.totals.queries);
+ try conn.request("GET", "/api/overview?period=1h&client=192.0.2.30,192.0.2.31", null, null);
+ const bad = try conn.receive(&body_buf);
+ try testing.expectEqual(@as(u16, 400), bad.status);
+ try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "client must be a single client address"));
+}
+
+test "W10 milestone 39: a client scope narrows every figure and its slices sum to the household" {
+ if (!build_options.integration) return error.SkipZigTest;
+
+ const gpa = testing.allocator;
+ var env = try Env.create(gpa, .{ .recent_traffic = true });
+ defer env.destroy();
+
+ try bounded(env.io(), default_budget, scopedAggregations, .{ env.io(), env });
+}
+
/// One connection walking every query-log endpoint several times over.
fn hammerQuerylog(io: std.Io, env: *Env) anyerror!void {
var conn: Conn = undefined;