admin: overview redesign, device scope, one formatting contract (milestone 39)
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 7m14s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 18m17s
Release / guard (push) Successful in 33s
Gates / test-aarch64 (push) Successful in 7m22s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m35s
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m16s
Gates / package (push) Successful in 44s
Release / publish (push) Successful in 10m4s
The Overview page takes the decided visual language (specs/ui-visual-redesign.md): four centred totals with their Activity links, a smoothed area chart of total and blocked queries with point hover and a tooltip centred beside the point, a stacked client chart in eight distinct hues plus one Other band that is always a series, and a card row with the cache hit rate, the query types as a single-hue ramp ring, and the upstream breakdown. The count axis grows its margin with the widest grouped tick and draws whole-number ticks only. GET /api/overview takes a client parameter; the scoped read uses idx_query_log_ts and the cache keeps scoped slots. The device selector beside the period selector is URL state, so a scoped view is a link, and the tile links carry the scope into Activity. The route reduces a pasted IPv6 scope to the RFC 5952 spelling the logger stores, mapped addresses included, and drops anything that is not an address. A failed device list says so under the selector with a retry. All measured quantities go through admin/src/lib/format.ts: grouped counts, two-decimal percentages, one-decimal rates, durations as the two largest nonzero units. Identifiers, configured values and preset labels render as written; the module header states that scope. A sweep test refuses toFixed, toLocaleString, Intl.NumberFormat and padStart anywhere else. Chrome: one 4px radius from the metrics constants, shared Card with a prominent title and a one-line description on every panel, the settings form sections on the same card with a floated legend, the sidebar grouped into Monitoring and System with a status block (protection, queries per minute on Overview, uptime), keyboard-focusable table scroll wrappers, and the accent darkened to 5.43:1 on its wash. Not built: the spec's ranked-list primitive, which has no consumer and no API rows. Codex reviewed sessions B to D over five rounds (thirty-three findings fixed, thirteen rejected as non-quantities); the owner skipped a sixth round. Claude-Session: https://claude.ai/code/session_01VTgx3a1zz1R78o4K55kkwR
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The cache hit rate as a progress bar (ui-visual-redesign.md): the share of
|
||||
* the window's queries answered from memory, the bar it fills, and under it the
|
||||
* three figures that share went with — hits, what went upstream instead, and
|
||||
* how long an answer took on average.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatCount, formatMicros, formatPercent } from "@/lib/format";
|
||||
import Card from "@/ui/Card";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
body: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
track: {
|
||||
height: "0.5rem",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: colors.chartGreenSurface,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
display: "block",
|
||||
height: "100%",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: colors.chartGreen,
|
||||
},
|
||||
/** Dynamic: the bar's length is the share itself. */
|
||||
fillWidth: (percent: number) => ({ width: `${percent}%` }),
|
||||
percent: {
|
||||
margin: 0,
|
||||
fontSize: "2.5rem",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.02em",
|
||||
color: colors.chartGreen,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
split: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1.5rem",
|
||||
margin: 0,
|
||||
marginTop: "0.5rem",
|
||||
paddingTop: "1rem",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
/** Label first in the DOM, figure on top visually. */
|
||||
splitItem: {
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
gap: "0.125rem",
|
||||
},
|
||||
splitValue: {
|
||||
margin: 0,
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.5rem",
|
||||
fontWeight: 550,
|
||||
color: colors.text,
|
||||
},
|
||||
splitLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export interface CacheCardData {
|
||||
queries: number;
|
||||
/** Answers served from cache: the window's `cached` buckets summed. */
|
||||
hits: number;
|
||||
/** Answers that went to an upstream resolver or a forward zone. */
|
||||
forwarded: number;
|
||||
avg_response_time_us: number | null;
|
||||
}
|
||||
|
||||
export default function CacheCard({ data }: { data: CacheCardData }) {
|
||||
const share = data.queries === 0 ? null : data.hits / data.queries;
|
||||
const percent = share === null ? "—" : formatPercent(share);
|
||||
return (
|
||||
<Card
|
||||
title="Cache hit rate"
|
||||
description="Answers served straight from memory, without asking an upstream resolver."
|
||||
>
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={
|
||||
share === null ? "No queries in this period" : `${percent} of queries served from cache`
|
||||
}
|
||||
{...stylex.props(styles.track)}
|
||||
>
|
||||
<span {...stylex.props(styles.fill, styles.fillWidth(share === null ? 0 : share * 100))} />
|
||||
</div>
|
||||
<p {...stylex.props(styles.percent, shared.tabularNums)}>{percent}</p>
|
||||
<p {...stylex.props(styles.note)}>of queries answered from cache</p>
|
||||
<dl {...stylex.props(styles.split)}>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>cache hits</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.hits)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>forwarded upstream</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>{formatCount(data.forwarded)}</dd>
|
||||
</div>
|
||||
<div {...stylex.props(styles.splitItem)}>
|
||||
<dt {...stylex.props(styles.splitLabel)}>avg response</dt>
|
||||
<dd {...stylex.props(styles.splitValue, shared.tabularNums)}>
|
||||
{data.avg_response_time_us === null ? "—" : formatMicros(data.avg_response_time_us)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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,52 @@ 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",
|
||||
"Other",
|
||||
"All clients",
|
||||
]);
|
||||
expect(screen.getByRole("table").querySelectorAll("th[scope=col]")).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
const { container } = render(TWO_BUCKETS);
|
||||
|
||||
@@ -252,26 +304,25 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
|
||||
* appear in the legend, the stack, the tooltip and the table saying only that it
|
||||
* is empty. The named clients stay at zero: a client that went quiet is a fact.
|
||||
*/
|
||||
test("a window where Other counted nothing drops it from every surface", () => {
|
||||
test("a window where Other counted nothing keeps it in the legend and the table, at zero", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30"]);
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
test("one query outside the named clients is enough to keep Other", () => {
|
||||
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));
|
||||
|
||||
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual(["Time", "192.0.2.30", "Other"]);
|
||||
|
||||
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.30", "Other", "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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -74,23 +49,20 @@ interface Series {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Other" last, so it sits at the top of every column rather than under a client,
|
||||
* and dropped entirely when it counted nothing across the window: an aggregation
|
||||
* bucket that aggregated nothing is a legend entry, a stack key, a tooltip row
|
||||
* and a table column all saying zero. The named clients stay at zero, because a
|
||||
* client that went quiet is something the reader wants to see.
|
||||
* "Other" last, so it sits at the top of every column rather than under a client.
|
||||
* It is always a series, even at zero across the window (ui-visual-redesign.md:
|
||||
* eight named clients plus Other): a reader comparing two scopes sees the same
|
||||
* legend in both, and a zero says the named clients were the whole story.
|
||||
*/
|
||||
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
||||
const named = data.clients.map((client) => ({
|
||||
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;
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
@@ -129,7 +101,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
|
||||
const timestamps = columns.map((column) => column.ts);
|
||||
const totals = columns.map((column) => series.reduce((sum, one) => sum + column[one.key], 0));
|
||||
const plot = plotArea(width);
|
||||
const plot = plotArea(width, Math.max(...totals));
|
||||
const xScale = bandScale(timestamps, plot);
|
||||
// Every series here is a disjoint part of the whole rather than a highlighted
|
||||
// subset of a separately reported total, so the tallest column's own sum is
|
||||
@@ -137,19 +109,30 @@ 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 Other, then the total — eight named
|
||||
* rows would be a table, and the reader pointing at a spike wants to know
|
||||
* who made it. Other is always there, at zero when the named clients were
|
||||
* the whole bucket, so the rows read the same from bucket to bucket.
|
||||
*/
|
||||
function tooltipOf(index: number): TooltipContent {
|
||||
const column = columns[index];
|
||||
const named = series
|
||||
.filter((one) => one.key !== OTHER_KEY && column[one.key] > 0)
|
||||
.sort((a, b) => column[b.key] - column[a.key])
|
||||
.slice(0, TOOLTIP_CLIENTS);
|
||||
const other = series.find((one) => one.key === OTHER_KEY);
|
||||
const rows = [...named, ...(other !== undefined ? [other] : [])].map((one) => ({
|
||||
key: one.key,
|
||||
label: one.label,
|
||||
color: one.color,
|
||||
value: formatCount(column[one.key]),
|
||||
}));
|
||||
return {
|
||||
title: formatTime(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]) }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,7 +140,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
|
||||
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -187,6 +170,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 (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
@@ -195,6 +179,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
bleed={restsOnAnother ? STACK_BLEED : 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -202,23 +187,12 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(hovered.index)}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
/>
|
||||
<ChartTooltip index={hovered.index} content={tooltipOf(hovered.index)} left={centers[hovered.index]} />
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartLegend entries={series} />
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per client per time bucket</caption>
|
||||
@@ -237,7 +211,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
{series.map((one) => (
|
||||
<td key={one.key}>{column[one.key]}</td>
|
||||
<td key={one.key}>{formatCount(column[one.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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(<Donut slices={[slice("a", 3000), slice("b", 1000)]} caption="Queries by DNS type" unit="Queries" />);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div {...stylex.props(styles.body)}>
|
||||
<div {...stylex.props(styles.ring)}>
|
||||
{/* 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. */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -213,7 +199,7 @@ export default function Donut({
|
||||
{/* Arc paths are generated around the origin, and `Pie`'s own
|
||||
`top`/`left` group is skipped when it is given a render prop, so
|
||||
the ring is centred here instead. */}
|
||||
<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
|
||||
<Group top={SIZE / 2} left={SIZE / 2}>
|
||||
<Pie
|
||||
data={drawn}
|
||||
pieValue={(slice) => slice.value}
|
||||
@@ -230,13 +216,10 @@ export default function Donut({
|
||||
>
|
||||
{({ arcs, path }) =>
|
||||
arcs.map((arc, index) => (
|
||||
// The stroke is what keeps a shared hue from lying. Colour is a
|
||||
// pure function of identity, so two neighbouring slices can come
|
||||
// out the same; outlined in the panel's own colour they still
|
||||
// read as two shapes rather than merging into one. Attributes
|
||||
// rather than a class, as the client chart's segments are, so
|
||||
// the separation is visible to a test and not only to a
|
||||
// stylesheet.
|
||||
// The stroke is what keeps a shared hue from lying: the routes
|
||||
// ring colours by identity, so two neighbouring slices can come
|
||||
// out the same, and outlined in the panel's own colour they
|
||||
// still read as two shapes rather than merging into one.
|
||||
<path
|
||||
key={arc.data.key}
|
||||
d={path(arc) ?? ""}
|
||||
@@ -249,6 +232,12 @@ export default function Donut({
|
||||
))
|
||||
}
|
||||
</Pie>
|
||||
<text y={-2} textAnchor="middle" {...stylex.props(styles.centerTotal, shared.tabularNums)}>
|
||||
{formatCount(total)}
|
||||
</text>
|
||||
<text y={15} textAnchor="middle" {...stylex.props(styles.centerUnit)}>
|
||||
{unit.toLowerCase()}
|
||||
</text>
|
||||
</Group>
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
@@ -269,11 +258,10 @@ export default function Donut({
|
||||
<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
|
||||
)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||
{numberFormat.format(slice.value)}
|
||||
</span>
|
||||
<span {...stylex.props(styles.share, shared.tabularNums)}>
|
||||
{sharePercent(slice.value / total)}
|
||||
<span {...stylex.props(styles.figures, shared.tabularNums)}>
|
||||
<span>{formatCount(slice.value)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span {...stylex.props(styles.share)}>{formatPercent(slice.value / total)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -296,8 +284,8 @@ export default function Donut({
|
||||
? slice.label
|
||||
: `${slice.label} (${slice.secondary})`}
|
||||
</th>
|
||||
<td>{slice.value}</td>
|
||||
<td>{sharePercent(slice.value / total)}</td>
|
||||
<td>{formatCount(slice.value)}</td>
|
||||
<td>{formatPercent(slice.value / total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -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
|
||||
@@ -8,19 +9,37 @@
|
||||
* the frame would drift. Nothing here imports a chart.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
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<Period, string> = {
|
||||
"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 +49,43 @@ 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%" },
|
||||
},
|
||||
/**
|
||||
* 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: {
|
||||
control: {
|
||||
minWidth: { default: "11rem", [NARROW]: 0 },
|
||||
flex: { default: null, [NARROW]: 1 },
|
||||
},
|
||||
/** Under the Device control: the list behind it did not load, so the control offers the household only. */
|
||||
devicesFailed: {
|
||||
margin: 0,
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
retry: {
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
backgroundColor: "transparent",
|
||||
font: "inherit",
|
||||
color: colors.primaryOnSurface,
|
||||
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 +95,72 @@ 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. A
|
||||
* list that failed to load is said so under the control, with a retry: the
|
||||
* household-only list is a failure, not the answer.
|
||||
*/
|
||||
function useDeviceOptions(client: string | undefined) {
|
||||
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 });
|
||||
}
|
||||
const { refetch } = clients;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
return { options, failed: clients.isError, retry };
|
||||
}
|
||||
|
||||
export function OverviewToolbar({
|
||||
scope,
|
||||
onChange,
|
||||
}: {
|
||||
scope: OverviewScope;
|
||||
onChange: (next: OverviewScope) => void;
|
||||
}) {
|
||||
const devices = useDeviceOptions(scope.client);
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label="Period"
|
||||
orientation="horizontal"
|
||||
value={period}
|
||||
onChange={(next) => onChange(next as Period)}
|
||||
className={() => stylex.props(styles.periodGroup).className ?? ""}
|
||||
>
|
||||
{PERIODS.map((option) => (
|
||||
<Radio
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.period,
|
||||
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||
isFocusVisible && styles.periodFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.control)}>
|
||||
<Select
|
||||
aria-label="Device"
|
||||
variant="toolbar"
|
||||
options={devices.options}
|
||||
value={scope.client ?? ALL_DEVICES}
|
||||
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
|
||||
/>
|
||||
{devices.failed && (
|
||||
<p role="alert" {...stylex.props(styles.devicesFailed)}>
|
||||
Device list unavailable.{" "}
|
||||
<button type="button" onClick={devices.retry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div {...stylex.props(styles.control)}>
|
||||
<Select
|
||||
aria-label="Period"
|
||||
variant="toolbar"
|
||||
options={PERIOD_OPTIONS}
|
||||
value={scope.period}
|
||||
onChange={(value) => onChange({ ...scope, period: value as Period })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,19 +173,19 @@ export function OverviewLoading() {
|
||||
}
|
||||
|
||||
export function OverviewFrame({
|
||||
period,
|
||||
scope,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
period: Period;
|
||||
onChange: (period: Period) => void;
|
||||
scope: OverviewScope;
|
||||
onChange: (next: OverviewScope) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div {...stylex.props(styles.page)}>
|
||||
<div {...stylex.props(styles.headingRow)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
||||
<PeriodPicker period={period} onChange={onChange} />
|
||||
<OverviewToolbar scope={scope} onChange={onChange} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
@@ -150,17 +193,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 (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<OverviewLoading />
|
||||
</OverviewFrame>
|
||||
);
|
||||
|
||||
@@ -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,13 +268,13 @@ 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 () => {
|
||||
// The fixture's other series is all zeroes. An aggregation bucket that
|
||||
// aggregated nothing is a legend entry and a table column that say only that
|
||||
// they are empty; the named clients stay, because a quiet client is a fact.
|
||||
test("the client chart keeps Other in a period where it counted nothing", async () => {
|
||||
// The fixture's other series is all zeroes. Other is still a series, so the
|
||||
// legend reads the same in every scope and its zero says the named clients
|
||||
// were the whole story.
|
||||
renderApp();
|
||||
await screen.findByRole("heading", { name: "Client activity over time" });
|
||||
|
||||
@@ -259,17 +283,27 @@ test("the client chart drops Other in a period where it counted nothing", async
|
||||
// Twice each: the legend swatch and the column header of the table a screen
|
||||
// reader gets instead of the graphic.
|
||||
await waitFor(() => expect(within(chart as HTMLElement).getAllByText("192.0.2.30")).toHaveLength(2));
|
||||
expect(within(chart as HTMLElement).queryAllByText("Other")).toHaveLength(0);
|
||||
expect(within(chart as HTMLElement).getAllByText("Other")).toHaveLength(2);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<Overview>; children: (data: Overview) => React.ReactNode }) {
|
||||
if (panel.status === "error") return <InlineError error={panel.error} onRetry={panel.retry} />;
|
||||
@@ -80,12 +58,13 @@ function PageBody({ panel, children }: { panel: Panel<Overview>; 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 (
|
||||
<OverviewFrame
|
||||
period={period}
|
||||
onChange={(next) => void navigate({ search: (prev) => ({ ...prev, period: next }) })}
|
||||
>
|
||||
<OverviewFrame scope={scope} onChange={setScope}>
|
||||
<PageBody panel={overview}>
|
||||
{(data) => (
|
||||
<>
|
||||
<StatTiles stats={{ since: data.since, until: data.until, ...data.totals }} />
|
||||
<StatTiles
|
||||
stats={{ since: data.since, until: data.until, client: scope.client, ...data.totals }}
|
||||
/>
|
||||
|
||||
{/* One notice for the page: every panel came out of this one
|
||||
response, so a second copy would only repeat this sentence. */}
|
||||
<CoverageNotice coverage={data.coverage} />
|
||||
|
||||
<section aria-labelledby="overview-queries" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-queries" {...stylex.props(styles.panelHeading)}>
|
||||
Queries over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Queries over time"
|
||||
description="Every query the resolver answered in this period, with the blocked share along the bottom."
|
||||
>
|
||||
<TimeseriesChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="overview-clients" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-clients" {...stylex.props(styles.panelHeading)}>
|
||||
Client activity over time
|
||||
</h2>
|
||||
<Card
|
||||
title="Client activity over time"
|
||||
description="Which devices made the queries, stacked per bucket."
|
||||
>
|
||||
<ClientChart data={data} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<div {...stylex.props(styles.donutRow)}>
|
||||
<section aria-labelledby="overview-types" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-types" {...stylex.props(styles.panelHeading)}>
|
||||
Query types
|
||||
</h2>
|
||||
<div {...stylex.props(styles.cards)}>
|
||||
<CacheCard data={cacheOf(data)} />
|
||||
<Card
|
||||
title="Query types"
|
||||
description="The record types clients asked for across this period."
|
||||
>
|
||||
<Donut slices={typeSlices(data.types)} caption="Queries by DNS type" unit="Queries" />
|
||||
</section>
|
||||
<section aria-labelledby="overview-routes" {...stylex.props(styles.panel)}>
|
||||
<h2 id="overview-routes" {...stylex.props(styles.panelHeading)}>
|
||||
Upstream servers
|
||||
</h2>
|
||||
</Card>
|
||||
<Card
|
||||
title="Upstream servers"
|
||||
description="How each query was answered: by which resolver, from cache, or not at all."
|
||||
>
|
||||
<Donut
|
||||
slices={routeSlices(data.routes)}
|
||||
caption="Queries by how they were answered"
|
||||
unit="Queries"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div {...stylex.props(styles.tile)}>
|
||||
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.valueRow)}>
|
||||
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||
</dd>
|
||||
{footer !== undefined && <div {...stylex.props(styles.footer)}>{footer}</div>}
|
||||
</div>
|
||||
<li {...stylex.props(styles.tile, style)}>
|
||||
<p
|
||||
{...stylex.props(
|
||||
styles.value,
|
||||
shared.tabularNums,
|
||||
tone === "blocked" && styles.valueBlocked,
|
||||
tone === "muted" && styles.valueMuted,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p {...stylex.props(styles.caption)}>{caption}</p>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<dl {...stylex.props(styles.grid)}>
|
||||
<ul aria-label="Totals" {...stylex.props(styles.grid)}>
|
||||
<Tile value={formatCount(stats.queries)} caption="queries">
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: undefined }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open in Activity
|
||||
</Link>
|
||||
</Tile>
|
||||
<Tile value={formatCount(stats.blocked)} caption="blocked queries" tone="blocked">
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: true }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open blocked queries
|
||||
</Link>
|
||||
</Tile>
|
||||
<Tile value={formatCount(stats.clients)} caption="active clients">
|
||||
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
|
||||
Manage clients
|
||||
</Link>
|
||||
</Tile>
|
||||
<Tile
|
||||
label="Queries"
|
||||
value={numberFormat.format(stats.queries)}
|
||||
footer={
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: undefined }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open in Activity
|
||||
</Link>
|
||||
}
|
||||
value={stats.queries === 0 ? "—" : formatPercent(stats.blocked / stats.queries)}
|
||||
caption="of queries blocked"
|
||||
tone="muted"
|
||||
style={styles.shareTile}
|
||||
/>
|
||||
<Tile
|
||||
label="Blocked"
|
||||
value={numberFormat.format(stats.blocked)}
|
||||
detail={percentOf(stats.blocked, stats.queries)}
|
||||
footer={
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ ...window, blocked: true }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Open blocked queries
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Clients"
|
||||
value={numberFormat.format(stats.clients)}
|
||||
footer={
|
||||
<Link to="/clients" {...stylex.props(styles.link, shared.focusRing)}>
|
||||
Manage clients
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
label="Avg response"
|
||||
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||
/>
|
||||
</dl>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SVGRectElement>('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(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
@@ -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(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 12, cached: 5 }])} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 10, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 5, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 3600, queries: 10, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 100, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 3600, queries: 0, blocked: 0, cached: 0 },
|
||||
{ ts: SINCE + 5400, queries: 0, blocked: 0, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
@@ -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(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
@@ -161,39 +248,62 @@ test("pointing at a bucket names its total and every series, and dims the rest",
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 1000, blocked: 400, cached: 10 },
|
||||
{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TimeseriesChart data={counting(2)} />);
|
||||
|
||||
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(
|
||||
<TimeseriesChart
|
||||
@@ -246,17 +356,17 @@ test("a refresh in the same window retells the hovered bucket with the new count
|
||||
const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
|
||||
(dd) => 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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
@@ -268,6 +378,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
|
||||
fireEvent.mouseOver(overlayRects(container)[0]);
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(1);
|
||||
expect(container.querySelector("g[data-hover]")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
@@ -282,8 +393,7 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
rerender(
|
||||
<TimeseriesChart
|
||||
@@ -299,9 +409,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
|
||||
|
||||
/**
|
||||
* The same bucket can change width between refreshes — a count crossing a digit
|
||||
* boundary, a client name arriving — and a mount measured at the old width is
|
||||
* placed at the wrong one. A content change therefore remounts the tooltip, the
|
||||
* same way moving between buckets does.
|
||||
* boundary — and a mount measured at the old width is placed at the wrong one.
|
||||
* A content change therefore remounts the tooltip, the same way moving between
|
||||
* buckets does.
|
||||
*/
|
||||
test("a bucket whose numbers change is remounted, so it is measured again", () => {
|
||||
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(<TimeseriesChart data={counting(3)} />);
|
||||
|
||||
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(
|
||||
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TimeseriesChart
|
||||
data={timeseries([
|
||||
{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
|
||||
{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
|
||||
])}
|
||||
/>,
|
||||
);
|
||||
test("the hidden table groups its counts like every other figure on the page", () => {
|
||||
render(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 12345, blocked: 1234, cached: 1 }])} />);
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -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<SeriesKey, string> = {
|
||||
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) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -125,18 +112,20 @@ 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 yScale = valueScale(Math.max(...columns.map((column) => column.queries)), [plot.bottom, plot.y]);
|
||||
const peak = Math.max(...columns.map((column) => column.queries));
|
||||
const plot = plotArea(width, peak);
|
||||
const xScale = pointScale(timestamps, plot);
|
||||
const yScale = valueScale(peak, [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 (
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={`Queries over time, ${data.buckets.length} buckets: ${SERIES.map((series) => series.label.toLowerCase()).join(", ")} queries per bucket`}
|
||||
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`}
|
||||
width="100%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -150,54 +139,61 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
bucketSeconds={data.bucket_seconds}
|
||||
/>
|
||||
<BarStack<Column, SeriesKey>
|
||||
data={columns}
|
||||
keys={SERIES.map((series) => series.key)}
|
||||
x={(column) => column.ts}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
color={(key) => SERIES_COLOR[key]}
|
||||
>
|
||||
{(stacks) =>
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
key={column.ts}
|
||||
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
|
||||
>
|
||||
{stacks.map((stack) => {
|
||||
const bar = stack.bars[index];
|
||||
return (
|
||||
<StackSegment
|
||||
key={stack.key}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
fill={bar.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))
|
||||
}
|
||||
</BarStack>
|
||||
<BucketOverlay plot={plot} values={timestamps} xScale={xScale} onEnter={hovered.show} />
|
||||
{SERIES.map((series) => {
|
||||
const points = pointsOf(series.key);
|
||||
const line = smoothPath(points, plot.y, plot.bottom);
|
||||
return (
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<path
|
||||
d={areaPath(line, points, plot)}
|
||||
fill={series.color}
|
||||
fillOpacity={series.fillOpacity}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{hovered.index !== null && (
|
||||
<g data-hover="">
|
||||
<line
|
||||
x1={centers[hovered.index]}
|
||||
x2={centers[hovered.index]}
|
||||
y1={plot.y}
|
||||
y2={plot.bottom}
|
||||
{...stylex.props(styles.guide)}
|
||||
/>
|
||||
{SERIES.map((series) => (
|
||||
<circle
|
||||
key={series.key}
|
||||
cx={centers[hovered.index as number]}
|
||||
cy={yScale(columns[hovered.index as number][series.key])}
|
||||
r={DOT_RADIUS}
|
||||
fill={series.color}
|
||||
{...stylex.props(styles.dot)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
left={slotCenter(xScale, timestamps[hovered.index], plot)}
|
||||
left={centers[hovered.index]}
|
||||
top={yScale(columns[hovered.index].queries)}
|
||||
beside
|
||||
/>
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{SERIES.map((series) => (
|
||||
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(series.color))} />
|
||||
{series.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ChartLegend
|
||||
entries={SERIES.map((series) => ({ key: series.key, label: series.label, color: series.color }))}
|
||||
/>
|
||||
<div {...stylex.props(shared.srOnly)}>
|
||||
<table>
|
||||
<caption>Queries per time bucket</caption>
|
||||
@@ -205,21 +201,17 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Queries</th>
|
||||
{SERIES.map((series) => (
|
||||
<th key={series.key} scope="col">
|
||||
{series.label}
|
||||
</th>
|
||||
))}
|
||||
<th scope="col">Blocked</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<td>{column.queries}</td>
|
||||
{SERIES.map((series) => (
|
||||
<td key={series.key}>{column[series.key]}</td>
|
||||
))}
|
||||
<td>{formatCount(column.queries)}</td>
|
||||
<td>{formatCount(column.blocked)}</td>
|
||||
<td>{formatCount(column.allowed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import {
|
||||
CHART_HEIGHT,
|
||||
ChartFrame,
|
||||
ChartTooltip,
|
||||
MARGIN,
|
||||
TICK_GLYPH_PX,
|
||||
TICK_LABEL_GAP_PX,
|
||||
bandPaddingInner,
|
||||
bandScale,
|
||||
labelTickValues,
|
||||
@@ -33,6 +38,91 @@ describe("valueScale", () => {
|
||||
expect(scale.ticks(5)).toEqual([0, 500, 1000, 1500]);
|
||||
expect(valueTicks(scale)).toEqual([0, 500, 1000, 1500]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A query count is a whole number. d3 answers a domain of [0, 1] with fifths,
|
||||
* and "0.5" queries is a quantity the data cannot hold, so the fractional
|
||||
* ticks are dropped from the grid and the axis alike.
|
||||
*/
|
||||
test("a single-digit peak is ticked in whole queries", () => {
|
||||
expect(valueTicks(valueScale(1, [240, 0]))).toEqual([0, 1]);
|
||||
expect(valueTicks(valueScale(2, [240, 0]))).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plotArea", () => {
|
||||
/**
|
||||
* The value labels are grouped counts, so a chart that peaks in the millions
|
||||
* needs more room to its left than the default margin: "1,250,000" does not
|
||||
* fit where "120" does. The width is measured on a quarter above the peak,
|
||||
* because the tick above it is the widest label drawn.
|
||||
*/
|
||||
test("the left margin grows with the widest value label", () => {
|
||||
expect(plotArea(640, 0).x).toBe(MARGIN.left);
|
||||
|
||||
const widest = formatCount(Math.ceil(1_000_000 * 1.25));
|
||||
expect(plotArea(640, 1_000_000).x).toBe(widest.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
expect(plotArea(640, 1_000_000).x).toBeGreaterThan(MARGIN.left);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `TooltipWithBounds` writes its own inline `transform` to place itself, and it
|
||||
* decides whether to flip above the anchor from that same number. Centring a
|
||||
* beside tooltip therefore has to reach the library as an `offsetTop`, measured
|
||||
* from the box, and not as a CSS transform the library cannot see.
|
||||
*/
|
||||
describe("ChartTooltip", () => {
|
||||
const BOX_HEIGHT = 40;
|
||||
|
||||
function withMeasuredBox(run: () => void) {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: () => BOX_HEIGHT,
|
||||
});
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, "offsetHeight", original);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTooltip(beside: boolean) {
|
||||
const { container } = render(
|
||||
<ChartTooltip
|
||||
content={{ title: "12:00", rows: [{ key: "queries", label: "Total queries", value: "7" }] }}
|
||||
index={0}
|
||||
left={100}
|
||||
top={100}
|
||||
beside={beside}
|
||||
/>,
|
||||
);
|
||||
return container.querySelector(".visx-tooltip") as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom measures nothing, so both rects are zero-sized: the library takes its
|
||||
* window branch, nothing is clipped at 100px into a 1024x768 window, and the
|
||||
* unflipped `translate(left + offsetLeft, top + offsetTop)` is what it writes.
|
||||
*/
|
||||
test("a beside tooltip is offset by half the height it measures", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(true).style.transform).toBe(`translate(116px, ${100 - BOX_HEIGHT / 2}px)`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a hung tooltip clears its anchor by the corner offset", () => {
|
||||
withMeasuredBox(() => {
|
||||
expect(renderTooltip(false).style.transform).toBe("translate(108px, 108px)");
|
||||
});
|
||||
});
|
||||
|
||||
/** An unmeasured box is not centred at all, rather than centred on a guess. */
|
||||
test("no measurable height leaves the tooltip on the anchor", () => {
|
||||
expect(renderTooltip(true).style.transform).toBe("translate(116px, 100px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandPaddingInner", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The plumbing the two bar charts on Overview share: the width measurement, the
|
||||
* scales, the axis and grid chrome, the segment separator, the per-bucket hit
|
||||
* target and the tooltip.
|
||||
* The plumbing the charts on Overview share: the width measurement, the scales,
|
||||
* the axis and grid chrome, the smoothed path, the stacked segment, the
|
||||
* per-bucket hit target and the tooltip.
|
||||
*
|
||||
* Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
|
||||
* marks, an axis line on both axes and Arial 10px in #222, none of which this
|
||||
@@ -13,14 +13,20 @@ import { useLayoutEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { formatBucketTime, formatCount } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors, layers } from "@/ui/tokens.stylex";
|
||||
import { colors, layers, metrics } from "@/ui/tokens.stylex";
|
||||
|
||||
export const CHART_HEIGHT = 240;
|
||||
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||
|
||||
/** The 10px tick label's glyph width; digits and the comma in a grouped count are all about this wide. */
|
||||
export const TICK_GLYPH_PX = 6;
|
||||
/** The tick label's gap to the axis (`dx` below) plus a little air. */
|
||||
export const TICK_LABEL_GAP_PX = 10;
|
||||
|
||||
/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
@@ -41,12 +47,20 @@ export interface Plot {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function plotArea(width: number): Plot {
|
||||
/**
|
||||
* The plot inside the axes. The left margin grows with the widest y label the
|
||||
* chart can show: counts are thousands-grouped everywhere, ticks included, so
|
||||
* "120,000" needs the room "120" does not. The tick above `maxValue` can add a
|
||||
* digit or a comma, so the width is measured on a quarter more.
|
||||
*/
|
||||
export function plotArea(width: number, maxValue = 0): Plot {
|
||||
const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
|
||||
const label = formatCount(Math.ceil(maxValue * 1.25));
|
||||
const left = Math.max(MARGIN.left, label.length * TICK_GLYPH_PX + TICK_LABEL_GAP_PX);
|
||||
return {
|
||||
x: MARGIN.left,
|
||||
x: left,
|
||||
y: MARGIN.top,
|
||||
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||
width: Math.max(0, width - left - MARGIN.right),
|
||||
height,
|
||||
bottom: MARGIN.top + height,
|
||||
};
|
||||
@@ -86,9 +100,13 @@ export function valueScale(max: number, range: [number, number]) {
|
||||
|
||||
export type ValueScale = ReturnType<typeof valueScale>;
|
||||
|
||||
/** The tick values the grid and the value axis both draw. */
|
||||
/**
|
||||
* The tick values the grid and the value axis both draw. A query count is a
|
||||
* whole number, so d3's fractional ticks — 0.5 of a query at a peak of 1 — are
|
||||
* dropped rather than labelled.
|
||||
*/
|
||||
export function valueTicks(scale: ValueScale): number[] {
|
||||
return scale.ticks(Y_TICK_COUNT);
|
||||
return scale.ticks(Y_TICK_COUNT).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +120,7 @@ export function bandPaddingInner(bucketCount: number, plotWidth: number): number
|
||||
return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
|
||||
}
|
||||
|
||||
/** The time axis: one band per bucket, in the order the buckets were given. */
|
||||
/** The time axis of a bar chart: one band per bucket, in the order the buckets were given. */
|
||||
export function bandScale(values: number[], plot: Plot) {
|
||||
return scaleBand<number>({
|
||||
domain: values,
|
||||
@@ -113,6 +131,19 @@ export function bandScale(values: number[], plot: Plot) {
|
||||
|
||||
export type BandScale = ReturnType<typeof bandScale>;
|
||||
|
||||
/**
|
||||
* The time axis of the area chart: the first bucket on the plot's left edge and
|
||||
* the last on its right, so the curve never runs past its own data and the
|
||||
* reader can see the last point.
|
||||
*/
|
||||
export function pointScale(values: number[], plot: Plot) {
|
||||
return scalePoint<number>({ domain: values, range: [plot.x, plot.x + plot.width] });
|
||||
}
|
||||
|
||||
export type PointScale = ReturnType<typeof pointScale>;
|
||||
|
||||
export type TimeScale = BandScale | PointScale;
|
||||
|
||||
/**
|
||||
* The subset of bucket timestamps that get an x-axis label. A 30-day window is
|
||||
* 30 columns and a 1-hour window is 60, so at narrow widths the labels have to
|
||||
@@ -124,14 +155,43 @@ export function labelTickValues(values: number[], plotWidth: number): number[] {
|
||||
return values.filter((_, i) => i % step === 0);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
/** One decimal of path precision: enough for a pixel, short enough to keep the DOM small. */
|
||||
function coordinate(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function formatBucketTime(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
/**
|
||||
* A curve through every point, as cubic Béziers derived from a Catmull-Rom
|
||||
* spline. The control points' y is clamped to the plot, because a spline
|
||||
* through a spike overshoots and would otherwise draw the curve dipping below
|
||||
* the baseline beside a zero bucket. The curve passes exactly through each
|
||||
* point, so the hover dots sit at the coordinates the path was built from.
|
||||
*/
|
||||
export function smoothPath(points: [number, number][], top: number, bottom: number): string {
|
||||
if (points.length === 0) return "";
|
||||
const clamp = (y: number) => Math.max(top, Math.min(bottom, y));
|
||||
const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))];
|
||||
let d = `M${coordinate(points[0][0])},${coordinate(points[0][1])}`;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const p0 = at(i - 1);
|
||||
const p1 = at(i);
|
||||
const p2 = at(i + 1);
|
||||
const p3 = at(i + 2);
|
||||
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
|
||||
const c1y = clamp(p1[1] + (p2[1] - p0[1]) / 6);
|
||||
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
|
||||
const c2y = clamp(p2[1] - (p3[1] - p1[1]) / 6);
|
||||
d += ` C${coordinate(c1x)},${coordinate(c1y)} ${coordinate(c2x)},${coordinate(c2y)} ${coordinate(p2[0])},${coordinate(p2[1])}`;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The same curve closed down to the baseline, for the fill under the line. */
|
||||
export function areaPath(line: string, points: [number, number][], plot: Plot): string {
|
||||
if (points.length === 0) return "";
|
||||
const first = coordinate(points[0][0]);
|
||||
const last = coordinate(points[points.length - 1][0]);
|
||||
return `${line} L${last},${plot.bottom} L${first},${plot.bottom} Z`;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
@@ -140,7 +200,7 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -155,15 +215,8 @@ const styles = stylex.create({
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
tooltip: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
borderRadius: "0.25rem",
|
||||
borderRadius: metrics.radius,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
@@ -174,6 +227,12 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** The positioning shell: `TooltipWithBounds` owns its inline transform, so nothing visual may sit here. */
|
||||
tooltipShell: {
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
zIndex: layers.tooltip,
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
@@ -201,7 +260,7 @@ const styles = stylex.create({
|
||||
height: "0.5rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||
/** Dynamic: the swatch takes the series colour the SVG shapes are drawn in. */
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
@@ -283,7 +342,7 @@ export function ChartFrame({
|
||||
plot: Plot;
|
||||
yScale: ValueScale;
|
||||
yTicks: number[];
|
||||
xScale: BandScale;
|
||||
xScale: TimeScale;
|
||||
xTickValues: number[];
|
||||
bucketSeconds: number;
|
||||
}) {
|
||||
@@ -298,7 +357,7 @@ export function ChartFrame({
|
||||
hideAxisLine
|
||||
hideTicks
|
||||
tickLength={0}
|
||||
tickFormat={(value) => compact.format(Number(value))}
|
||||
tickFormat={(value) => formatCount(Number(value))}
|
||||
// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
|
||||
// with the middle baseline this app centres its value labels on.
|
||||
tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
|
||||
@@ -320,9 +379,9 @@ export function ChartFrame({
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment of a stacked column. The separator is drawn only once the column
|
||||
* is wide enough for two neighbouring segments to read as two shapes; below
|
||||
* that it would be most of the bar.
|
||||
* One segment of a stacked column. `bleed` extends the segment down into the
|
||||
* one drawn before it, so antialiasing cannot open a seam of ground between
|
||||
* two touching fills: the blank lines the owner saw across the client chart.
|
||||
*/
|
||||
export function StackSegment({
|
||||
x,
|
||||
@@ -330,59 +389,54 @@ export function StackSegment({
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
bleed = 0,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
bleed?: number;
|
||||
}) {
|
||||
if (height <= 0) return null;
|
||||
return (
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
fill={fill}
|
||||
strokeWidth={width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
);
|
||||
return <rect x={x} y={y} width={width} height={height + bleed} fill={fill} />;
|
||||
}
|
||||
|
||||
/** The amount one upper segment overlaps the one under it, in plot units. */
|
||||
export const STACK_BLEED = 0.5;
|
||||
|
||||
/**
|
||||
* The transparent hit targets: one per bucket, spanning the whole plot height so
|
||||
* that pointing at the empty space above a short column still selects it.
|
||||
* that pointing at the empty space above a short column or a low point still
|
||||
* selects it.
|
||||
*
|
||||
* A slot is a whole band step, gap included, so that no pixel between two
|
||||
* columns belongs to neither. The last slot is clipped to the plot's right edge:
|
||||
* the band scale spends the trailing gap on nothing, and a full-step rect there
|
||||
* would reach into the right margin.
|
||||
* The bands tile the plot edge to edge with their boundaries midway between
|
||||
* neighbouring marks, so no pixel belongs to no bucket and landing in a band is
|
||||
* already "snap to the nearest bucket". The same tiling serves band centres
|
||||
* (bars) and points spread edge to edge (the area chart).
|
||||
*/
|
||||
export function BucketOverlay({
|
||||
export function HitBands({
|
||||
plot,
|
||||
values,
|
||||
xScale,
|
||||
centers,
|
||||
onEnter,
|
||||
}: {
|
||||
plot: Plot;
|
||||
values: number[];
|
||||
xScale: BandScale;
|
||||
/** The x of each bucket's mark, in bucket order. */
|
||||
centers: number[];
|
||||
onEnter: (index: number) => void;
|
||||
}) {
|
||||
const step = xScale.step();
|
||||
const right = plot.x + plot.width;
|
||||
return (
|
||||
<>
|
||||
{values.map((value, index) => {
|
||||
const x = xScale(value) ?? plot.x;
|
||||
{centers.map((cx, index) => {
|
||||
const x0 = index === 0 ? plot.x : (centers[index - 1] + cx) / 2;
|
||||
const x1 = index === centers.length - 1 ? right : (cx + centers[index + 1]) / 2;
|
||||
return (
|
||||
<rect
|
||||
key={value}
|
||||
x={x}
|
||||
key={index}
|
||||
x={x0}
|
||||
y={plot.y}
|
||||
width={Math.max(0, Math.min(step, right - x))}
|
||||
width={Math.max(0, x1 - x0)}
|
||||
height={plot.height}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => onEnter(index)}
|
||||
@@ -393,13 +447,13 @@ export function BucketOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
/** The x a bar's tooltip points at: the centre of its slot, not of its narrower bar. */
|
||||
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
|
||||
return (xScale(value) ?? plot.x) + xScale.step() / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which item the pointer is on, and nothing else — a bucket in the bar charts, a
|
||||
* Which item the pointer is on, and nothing else — a bucket in the charts, a
|
||||
* slice in the donuts.
|
||||
*
|
||||
* Deliberately not `useTooltip`: holding the hovered item's numbers and screen
|
||||
@@ -444,24 +498,41 @@ export interface TooltipContent {
|
||||
/**
|
||||
* `TooltipWithBounds` positions itself with an inline transform and drops it
|
||||
* when `unstyled` is set, so the default look is replaced by handing it an empty
|
||||
* style object rather than by turning styling off.
|
||||
* style object rather than by turning styling off. What it keeps is the
|
||||
* positioning; the box the reader sees is the element inside it.
|
||||
*/
|
||||
const NO_INLINE_STYLE = {};
|
||||
|
||||
/** The gap the tooltip keeps from its anchor point. */
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
/** The gap a tooltip hung from a chart's corner keeps from its anchor point. */
|
||||
const CORNER_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* The gap a tooltip beside a point keeps from it: enough that the box never
|
||||
* covers the dot or the slot of data the reader is looking at.
|
||||
*/
|
||||
const BESIDE_OFFSET = 16;
|
||||
|
||||
export function ChartTooltip({
|
||||
content,
|
||||
index,
|
||||
left,
|
||||
top = 0,
|
||||
beside = false,
|
||||
}: {
|
||||
content: TooltipContent;
|
||||
/** Which item the tooltip names; part of what forces a fresh measurement. */
|
||||
index: number;
|
||||
left: number;
|
||||
top?: number;
|
||||
/**
|
||||
* Beside the anchor at its own height rather than hung from the chart's top:
|
||||
* the area chart's placement, where `top` is the point's y. The centring is a
|
||||
* negative half-height `offsetTop`, not a CSS transform, so that the flip
|
||||
* `TooltipWithBounds` computes from its own rect sees where the box really
|
||||
* lands. It flips the box to the anchor's left when it would run past the
|
||||
* right edge, and above the anchor when it would run past the bottom.
|
||||
*/
|
||||
beside?: boolean;
|
||||
}) {
|
||||
// `withBoundingRects` measures once, in `componentDidMount`, and never again,
|
||||
// so every content change needs its own mount to be measured at its own size.
|
||||
@@ -469,33 +540,88 @@ export function ChartTooltip({
|
||||
// a digit boundary, or resolve a client's name, and the stale width would place
|
||||
// it wrongly at the right edge.
|
||||
const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
// The centring offset is half the box's own height, so it cannot be known
|
||||
// before the box exists. `measureKey` remounts the whole tooltip per content
|
||||
// change, so one measurement per mount covers every size the box takes.
|
||||
useLayoutEffect(() => {
|
||||
if (box.current) setHeight(box.current.offsetHeight);
|
||||
}, []);
|
||||
return (
|
||||
<TooltipWithBounds
|
||||
key={measureKey}
|
||||
left={left}
|
||||
top={top}
|
||||
offsetLeft={TOOLTIP_OFFSET}
|
||||
offsetTop={TOOLTIP_OFFSET}
|
||||
offsetLeft={beside ? BESIDE_OFFSET : CORNER_OFFSET}
|
||||
offsetTop={beside ? -height / 2 : CORNER_OFFSET}
|
||||
style={NO_INLINE_STYLE}
|
||||
className={stylex.props(styles.tooltip).className}
|
||||
className={stylex.props(styles.tooltipShell).className}
|
||||
>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
{content.rows.map((row) => (
|
||||
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
{row.color !== undefined && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
|
||||
/>
|
||||
)}
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div ref={box} {...stylex.props(styles.tooltip)}>
|
||||
<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
|
||||
<dl {...stylex.props(styles.tooltipList)}>
|
||||
{content.rows.map((row) => (
|
||||
<div key={row.key} {...stylex.props(styles.tooltipRow)}>
|
||||
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||
{row.color !== undefined && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
|
||||
/>
|
||||
)}
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</TooltipWithBounds>
|
||||
);
|
||||
}
|
||||
|
||||
const legendStyles = stylex.create({
|
||||
legend: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
columnGap: "1rem",
|
||||
rowGap: "0.25rem",
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
item: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
swatch: {
|
||||
display: "inline-block",
|
||||
width: "0.625rem",
|
||||
height: "0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
},
|
||||
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||
});
|
||||
|
||||
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
|
||||
export function ChartLegend({ entries }: { entries: { key: string; label: string; color: string }[] }) {
|
||||
return (
|
||||
<ul {...stylex.props(legendStyles.legend)}>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.key} {...stylex.props(legendStyles.item)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
{...stylex.props(legendStyles.swatch, legendStyles.swatchColor(entry.color))}
|
||||
/>
|
||||
{entry.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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("::1")).toBe("::1");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("fe80::1%25eth0")).toBeUndefined();
|
||||
// An IPv4 tail is the last 32 bits, so nothing may follow it.
|
||||
expect(parseClient("192.0.2.30::")).toBeUndefined();
|
||||
expect(parseClient("::192.0.2.30:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8:0:0:0:0:0:0:1")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1::2")).toBeUndefined();
|
||||
expect(parseClient("2001:db8::1:2:3:4:5:6:7")).toBeUndefined();
|
||||
expect(parseClient("256.0.0.1")).toBeUndefined();
|
||||
expect(parseClient("192.0.2")).toBeUndefined();
|
||||
expect(parseClient(" 192.0.2.30")).toBeUndefined();
|
||||
expect(parseClient("abc")).toBeUndefined();
|
||||
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();
|
||||
});
|
||||
|
||||
test("an IPv6 address is reduced to the spelling the logger stores", () => {
|
||||
expect(parseClient("2001:DB8::1")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe("2001:db8::1");
|
||||
expect(parseClient("2001:db8:0:0:1:0:0:1")).toBe("2001:db8::1:0:0:1");
|
||||
expect(parseClient("1:0:0:1:0:0:0:1")).toBe("1:0:0:1::1");
|
||||
expect(parseClient("0:0:0:0:0:0:0:0")).toBe("::");
|
||||
expect(parseClient("2001:db8:0:1:1:1:1:1")).toBe("2001:db8:0:1:1:1:1:1");
|
||||
});
|
||||
|
||||
/**
|
||||
* `src/platform/address.zig` prints hex groups with RFC 5952 compression and
|
||||
* nothing else, and it normalizes an IPv4-mapped address to the plain IPv4, so
|
||||
* a dotted tail in a pasted link has to be folded the same way to match.
|
||||
*/
|
||||
test("an IPv4 tail is folded into hextets, and a mapped address into its IPv4", () => {
|
||||
expect(parseClient("2001:db8::192.0.2.30")).toBe("2001:db8::c000:21e");
|
||||
expect(parseClient("::ffff:192.0.2.30")).toBe("192.0.2.30");
|
||||
expect(parseClient("::FFFF:C000:021E")).toBe("192.0.2.30");
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The device Overview is scoped to, as URL state beside the period.
|
||||
*
|
||||
* The API takes one exact address (no list) and matches the text the logger
|
||||
* stored, so that is what the route lets through: a value that is not an IPv4
|
||||
* or IPv6 address is dropped from the URL rather than sent on to a 400, and an
|
||||
* IPv6 address is reduced to the RFC 5952 spelling the logger writes, so an
|
||||
* uppercase or expanded form in a pasted link still finds its client. The
|
||||
* server writes hex groups only, so an IPv4 tail is folded into the two hextets
|
||||
* it names and an IPv4-mapped address is reduced to its plain IPv4. An absent
|
||||
* scope is the whole household.
|
||||
*/
|
||||
|
||||
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||
const HEXTET = /^[0-9a-f]{1,4}$/i;
|
||||
|
||||
/** The two hextets an IPv4 tail names: `192.0.2.30` is `c000:21e`. */
|
||||
function ipv4Hextets(dotted: string): string[] {
|
||||
const octets = dotted.split(".").map(Number);
|
||||
return [((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
|
||||
}
|
||||
|
||||
/**
|
||||
* One side of a `::` as hextets, or null. An IPv4 tail is folded into the two
|
||||
* hextets it names — the server prints an IPv6 address as hex groups only,
|
||||
* never with a dotted tail — and it is read only where the address ends, since
|
||||
* the tail is the last 32 bits and nothing may follow it.
|
||||
*/
|
||||
function sideHextets(parts: string[], dottedAllowed: boolean): string[] | null {
|
||||
const last = parts[parts.length - 1];
|
||||
const hextets =
|
||||
last !== undefined && last.includes(".")
|
||||
? dottedAllowed && IPV4.test(last)
|
||||
? [...parts.slice(0, -1), ...ipv4Hextets(last)]
|
||||
: null
|
||||
: parts;
|
||||
if (hextets === null) return null;
|
||||
return hextets.every((group) => HEXTET.test(group)) ? hextets : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 4291 text — up to eight hextets, one `::` at most, an IPv4 tail allowed
|
||||
* as the last 32 bits — parsed to its eight hextets, or null.
|
||||
*/
|
||||
function ipv6Groups(value: string): string[] | null {
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const split = halves.length === 2;
|
||||
const head = sideHextets(halves[0] === "" ? [] : (halves[0] as string).split(":"), !split);
|
||||
const tail = sideHextets(split && halves[1] !== "" ? (halves[1] as string).split(":") : [], split);
|
||||
if (head === null || tail === null) return null;
|
||||
const width = head.length + tail.length;
|
||||
if (split ? width >= 8 : width !== 8) return null;
|
||||
const zeros = Array.from({ length: 8 - width }, () => "0");
|
||||
const expanded = split ? [...head, ...zeros, ...tail] : head;
|
||||
return expanded.map((group) => group.replace(/^0+(?=.)/, "").toLowerCase());
|
||||
}
|
||||
|
||||
/** RFC 5952: lowercase, no leading zeros, the longest run of two or more zero groups as `::` (the first on a tie). */
|
||||
function compressIpv6(groups: string[]): string {
|
||||
let best = { start: -1, length: 0 };
|
||||
for (let i = 0; i < groups.length;) {
|
||||
if (groups[i] !== "0") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < groups.length && groups[j] === "0") j += 1;
|
||||
if (j - i >= 2 && j - i > best.length) best = { start: i, length: j - i };
|
||||
i = j;
|
||||
}
|
||||
if (best.start < 0) return groups.join(":");
|
||||
const head = groups.slice(0, best.start).join(":");
|
||||
const tail = groups.slice(best.start + best.length).join(":");
|
||||
return `${head}::${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dotted IPv4 an IPv4-mapped address stands for, or null. The server
|
||||
* normalizes `::ffff:a.b.c.d` to the plain `a.b.c.d`, so a link that spells the
|
||||
* mapped form has to be reduced the same way to find its client.
|
||||
*/
|
||||
function mappedIpv4(groups: string[]): string | null {
|
||||
if (groups.slice(0, 5).some((group) => group !== "0") || groups[5] !== "ffff") return null;
|
||||
const high = Number.parseInt(groups[6] as string, 16);
|
||||
const low = Number.parseInt(groups[7] as string, 16);
|
||||
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||
}
|
||||
|
||||
export function parseClient(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (IPV4.test(value)) return value;
|
||||
const groups = ipv6Groups(value);
|
||||
if (groups === null) return undefined;
|
||||
return mappedIpv4(groups) ?? compressIpv6(groups);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
* completion. One request cannot disagree with itself, so those behaviours have
|
||||
* no subject left and are gone rather than ported. What survived the collapse is
|
||||
* pinned below: the three states, and the one rule a single request still does
|
||||
* not settle — that a `keepPreviousData` body from the period the reader left
|
||||
* must never render under the new period's label.
|
||||
* not settle — that the body of the scope the reader just left, period or
|
||||
* device, must never render under the new scope's label.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
@@ -63,8 +63,8 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
/** The last retry the hook handed out, so a test can spend it. */
|
||||
let lastRetry: () => void;
|
||||
|
||||
function Probe({ period }: { period: Period }) {
|
||||
const panel = useOverviewWindow(period);
|
||||
function Probe({ period, client }: { period: Period; client?: string }) {
|
||||
const panel = useOverviewWindow(period, client);
|
||||
if (panel.status === "error") lastRetry = panel.retry;
|
||||
const detail =
|
||||
panel.status === "ready"
|
||||
@@ -75,18 +75,18 @@ function Probe({ period }: { period: Period }) {
|
||||
return <p>{`${panel.status}:${detail}`}</p>;
|
||||
}
|
||||
|
||||
function renderProbe(period: Period = "24h") {
|
||||
function renderProbe(period: Period = "24h", scope?: string) {
|
||||
const client = createQueryClient();
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={period} />
|
||||
<Probe period={period} client={scope} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
rerenderWith: (next: Period) =>
|
||||
rerenderWith: (next: Period, nextScope?: string) =>
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<Probe period={next} />
|
||||
<Probe period={next} client={nextScope} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
@@ -115,13 +115,24 @@ test("a failed request is one error for the whole page, with a retry that refetc
|
||||
expect(calls).toBeGreaterThan(spent);
|
||||
});
|
||||
|
||||
test("a retained previous-period body never renders under the new period's label", async () => {
|
||||
test("a previous period's body never renders under the new period's label", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
rerenderWith("1h");
|
||||
// `keepPreviousData` is holding the 24h body. It is a complete answer and
|
||||
// still the wrong one to draw under "1h", so the page waits.
|
||||
// The 24h body is a complete answer and still the wrong one to draw under
|
||||
// "1h", so the page waits for its own.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:1h@${UNTIL}`));
|
||||
});
|
||||
|
||||
test("rescoping to a device under the same period waits for that device's body", async () => {
|
||||
const { rerenderWith } = renderProbe("24h");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
|
||||
rerenderWith("24h", "192.0.2.30");
|
||||
// Same period, so the household body would pass a period check; it is
|
||||
// still the wrong scope to draw under the device's name.
|
||||
expect(line()).toBe("loading:");
|
||||
await waitFor(() => expect(line()).toBe(`ready:24h@${UNTIL}`));
|
||||
});
|
||||
|
||||
@@ -8,27 +8,27 @@
|
||||
* breakdowns describe the same span and the same database state by construction,
|
||||
* and none of that reconciliation has anything left to reconcile.
|
||||
*
|
||||
* What remains is the one rule a single request does not settle by itself.
|
||||
* `keepPreviousData` holds the body of the period the reader just left — a
|
||||
* complete, self-consistent answer, and still the wrong one to draw under the
|
||||
* new label — so a body is a member of this window only while its own `period`
|
||||
* is the selected one. Until then the page is loading.
|
||||
* The query key carries the period and the client, so the body the hook
|
||||
* returns is always the body of the scope the toolbar names. A rescope shows
|
||||
* the loading state until its own answer lands rather than the previous
|
||||
* scope's charts under the new label: a complete, self-consistent body for the
|
||||
* wrong device is still the wrong body.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { overviewQuery } from "@/lib/queries";
|
||||
import type { Overview, Period } from "@/lib/types";
|
||||
|
||||
export type Panel<T> =
|
||||
{ status: "loading" } | { status: "error"; error: unknown; retry: () => void } | { status: "ready"; data: T };
|
||||
|
||||
export function useOverviewWindow(period: Period): Panel<Overview> {
|
||||
const query = useQuery({ ...overviewQuery(period), placeholderData: keepPreviousData });
|
||||
export function useOverviewWindow(period: Period, client: string | undefined): Panel<Overview> {
|
||||
const query = useQuery(overviewQuery(period, client));
|
||||
const { refetch } = query;
|
||||
const retry = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
if (query.isError) return { status: "error", error: query.error, retry };
|
||||
if (query.data !== undefined && query.data.period === period) return { status: "ready", data: query.data };
|
||||
if (query.data !== undefined) return { status: "ready", data: query.data };
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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];
|
||||
|
||||
Reference in New Issue
Block a user