admin: the overview's last bucket is drawn as still counting; the query detail names the device as the activity list does

This commit is contained in:
2026-10-20 11:20:16 +02:00
parent 8efe504c51
commit ea9f16a989
8 changed files with 226 additions and 56 deletions
+2
View File
@@ -9,6 +9,8 @@ Sections are written by hand. Nothing here is generated from commit messages: th
### Fixed ### Fixed
- **A blackhole route to an upstream no longer stops failover.** On a route like that, `connect()` fails with an errno the runtime has no name for, and nxdns took it for a fault of its own: the query got SERVFAIL at once, the next upstream was never tried, and nothing was charged to the unreachable one. A connect failure of that kind now counts against that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over. - **A blackhole route to an upstream no longer stops failover.** On a route like that, `connect()` fails with an errno the runtime has no name for, and nxdns took it for a fault of its own: the query got SERVFAIL at once, the next upstream was never tried, and nothing was charged to the unreachable one. A connect failure of that kind now counts against that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over.
- **The Overview charts no longer draw the current bucket as a fall in traffic.** Every Overview window ends at the end of the bucket that holds the present moment, so its last bucket is still counting. Queries over time draws the curve into it dashed over a fainter fill, and Client activity draws its column faint inside a dashed outline. Both legends name "Still counting", and the tooltip and the chart's data table give that bucket's time as "<time> · still counting".
- **The query detail page names the device.** The Client fact showed only the address, while the Activity list showed "iPhone (192.168.50.52)" for the same query. It now uses the list's label. The query log records only the address, so the record's intro says the device name is the one the address has today.
### Added ### Added
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -7,6 +8,7 @@ import { createAppRouter } from "@/routes";
import type { QueryDetail } from "@/lib/types"; import type { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/provenance/provenanceFixture"; import { provenance } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture"; import { health } from "@/lib/healthFixture";
import { styles as shared } from "@/ui/styles";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail { function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) }; return { id, ...provenance(sections) };
@@ -282,24 +284,40 @@ async function relatedText(expected: string) {
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected)); await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
} }
test("the record keeps the address the query came from, and Related carries the name it has now", async () => { /**
* The Activity list labels a client "Kids iPad (192.0.2.12)"; the detail page
* labels the same query the same way. The address is the recorded fact, and the
* intro says once that the device name is today's.
*/
test("the client is the device name with its address, as in the Activity list", async () => {
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } }); responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" }); responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
renderDetail(12); renderDetail(12);
await screen.findByRole("heading", { name: "shop.example" }); await screen.findByRole("heading", { name: "shop.example" });
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”."); await waitFor(() => expect(factValue("Client")).toBe("Kids iPad (192.0.2.12)"));
expect(factValue("Client")).toBe("192.0.2.12"); expect(screen.getByText(/The device name is the one its address has today\./)).toBeTruthy();
}); });
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => { test("an address the client list does not know stays a bare address", async () => {
responses["/api/queries/14"] = detail(14, { request: { client: "192.0.2.14" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.99", name: "Elsewhere", learned_name: "" });
renderDetail(14);
await screen.findByRole("heading", { name: "example.com" });
await relatedText("These read the current configuration");
expect(factValue("Client")).toBe("192.0.2.14");
});
test("a learned name is drawn as learned, as in the Activity list", async () => {
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } }); responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" }); responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
renderDetail(13); renderDetail(13);
await screen.findByRole("heading", { name: "example.com" }); await screen.findByRole("heading", { name: "example.com" });
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan."); await waitFor(() => expect(factValue("Client")).toBe("printer.lan (192.0.2.13)"));
expect(factValue("Client")).toBe("192.0.2.13"); const label = screen.getByText("Client").nextElementSibling?.firstElementChild;
expect(label?.className).toBe(stylex.props(shared.learnedName).className);
}); });
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => { test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format"; import { formatMicros, formatTime } from "@/lib/format";
import type { PolicyReason, Provenance } from "@/lib/types"; import type { PolicyReason, Provenance } from "@/lib/types";
import { clientLabel, useClientNames } from "@/features/clients/clientNames"; import { ClientName, useClientNames } from "@/features/clients/clientNames";
import { import {
policyActionLabel, policyActionLabel,
policyReasonLabel, policyReasonLabel,
@@ -178,7 +178,6 @@ interface Props {
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) { export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
const { request, group, policy, rewrites, route, response } = provenance; const { request, group, policy, rewrites, route, response } = provenance;
const clientNames = useClientNames(); const clientNames = useClientNames();
const currentClient = clientLabel(request.client, clientNames);
return ( return (
<> <>
@@ -192,7 +191,8 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<div {...stylex.props(styles.record)}> <div {...stylex.props(styles.record)}>
<p {...stylex.props(styles.recordNote)}> <p {...stylex.props(styles.recordNote)}>
What was recorded when this query was answered. Group and blocklist names are the ones in force at What was recorded when this query was answered. Group and blocklist names are the ones in force at
that moment; they may have been renamed or deleted since. that moment; they may have been renamed or deleted since. The device name is the one its address has
today.
{persistedId === null && {persistedId === null &&
" This is the event as it was streamed; the query log may not have written it yet."} " This is the event as it was streamed; the query log may not have written it yet."}
</p> </p>
@@ -203,7 +203,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<Mono>{request.domain}</Mono> <Mono>{request.domain}</Mono>
</Fact> </Fact>
<Fact label="Client"> <Fact label="Client">
<Mono>{request.client}</Mono> <ClientName ip={request.client} names={clientNames} />
</Fact> </Fact>
<Fact label="Type">{qtypeName(request.qtype)}</Fact> <Fact label="Type">{qtypeName(request.qtype)}</Fact>
<Fact label="Class">{qclassName(request.qclass)}</Fact> <Fact label="Class">{qclassName(request.qclass)}</Fact>
@@ -284,20 +284,6 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<p {...stylex.props(styles.relatedNote)}> <p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query. These read the current configuration, which may no longer be the one that decided this query.
</p> </p>
{currentClient !== null && (
<p {...stylex.props(styles.relatedNote)}>
{currentClient.learned ? (
<>
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
<Mono>{currentClient.text}</Mono>.
</>
) : (
<>
The client list currently names <Mono>{request.client}</Mono> “{currentClient.text}”.
</>
)}
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div> <div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</section> </section>
</> </>
@@ -4,6 +4,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart"; import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors"; import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
import { STILL_COUNTING_OPACITY } from "./chartKit";
const SINCE = 1_700_000_000; const SINCE = 1_700_000_000;
const BUCKET = 1800; const BUCKET = 1800;
@@ -73,7 +74,10 @@ test("the columns are timestamped from this response's own window", () => {
render(TWO_BUCKETS); render(TWO_BUCKETS);
const rows = within(screen.getByRole("table")).getAllByRole("rowheader"); const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]); expect(rows.map((row) => row.textContent)).toEqual([
formatTime(SINCE),
`${formatTime(SINCE + BUCKET)} · still counting`,
]);
}); });
/** /**
@@ -100,7 +104,7 @@ test("the series are coloured by rank, and Other in its own gray", () => {
), ),
); );
const fills = Array.from(container.querySelectorAll("rect")) const fills = Array.from(container.querySelectorAll("rect:not([data-still-counting])"))
.map((rect) => rect.getAttribute("fill")) .map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent"); .filter((fill) => fill !== "transparent");
expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]); expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
@@ -209,8 +213,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET }); rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
expect(container.querySelectorAll("dl")).toHaveLength(0); expect(container.querySelectorAll("dl")).toHaveLength(0);
// No dimming is left; the last column stays faint because it is still counting.
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true); expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
}); });
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */ /** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
@@ -261,7 +266,7 @@ test("pointing at a bucket names its busiest clients, then Other, then the total
expect(swatches[2]).toContain(seriesColor(OTHER_KEY)); expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]); expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(0.55 * STILL_COUNTING_OPACITY)]);
}); });
/** /**
@@ -294,8 +299,9 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement); fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0); expect(container.querySelectorAll("dl")).toHaveLength(0);
// No dimming is left; the last column stays faint because it is still counting.
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true); expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
}); });
/** /**
@@ -310,6 +316,7 @@ test("a window where Other counted nothing keeps it in the legend and the table,
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([ expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
"192.0.2.30", "192.0.2.30",
"Other", "Other",
"Still counting",
]); ]);
expect( expect(
within(screen.getByRole("table")) within(screen.getByRole("table"))
@@ -326,3 +333,28 @@ test("the hidden table groups its counts like every other figure on the page", (
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0])); render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy(); expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
}); });
/**
* The API ends every window at the end of the bucket that holds "now", so the
* last column is still filling. Drawn like the others it reads as a collapse in
* traffic; it is drawn faint inside a dashed outline, and every text says so.
*/
test("the last column is still counting, and says so in the drawing, the tooltip and the table", () => {
const { container } = render(TWO_BUCKETS);
const outlines = container.querySelectorAll("rect[data-still-counting]");
expect(outlines).toHaveLength(1);
expect(outlines[0].getAttribute("stroke-dasharray")).toBeTruthy();
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
fireEvent.mouseOver(overlayRects(container)[1]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + BUCKET)} · still counting`);
});
test("a last column with nothing in it yet draws no outline", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 0] }], [2, 0]));
expect(container.querySelectorAll("rect[data-still-counting]")).toHaveLength(0);
expect(Array.from(container.querySelectorAll("ul li")).at(-1)?.textContent).toBe("Still counting");
});
+34 -6
View File
@@ -12,9 +12,10 @@
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group"; import { Group } from "@visx/group";
import { BarStack } from "@visx/shape"; import { BarStack } from "@visx/shape";
import { formatCount, formatTime } from "@/lib/format"; import { formatCount } from "@/lib/format";
import type { OverviewClientSeries } from "@/lib/types"; import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames"; import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { import {
CHART_HEIGHT, CHART_HEIGHT,
@@ -25,8 +26,11 @@ import {
EmptyChart, EmptyChart,
HitBands, HitBands,
STACK_BLEED, STACK_BLEED,
STILL_COUNTING_DASH,
STILL_COUNTING_OPACITY,
StackSegment, StackSegment,
bandScale, bandScale,
bucketTitle,
labelTickValues, labelTickValues,
plotArea, plotArea,
slotCenter, slotCenter,
@@ -38,6 +42,15 @@ import {
} from "./chartKit"; } from "./chartKit";
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors"; import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
const styles = stylex.create({
/** The column's outline, over its faint fill: the bucket that is still filling. */
stillCounting: {
fill: "none",
stroke: colors.textSecondary,
strokeWidth: 1,
},
});
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */ /** How many named clients a bucket's tooltip lists before the aggregate and the total. */
const TOOLTIP_CLIENTS = 4; const TOOLTIP_CLIENTS = 4;
@@ -110,6 +123,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const yTicks = valueTicks(yScale); const yTicks = valueTicks(yScale);
const colorOf = new Map(series.map((one) => [one.key, one.color])); const colorOf = new Map(series.map((one) => [one.key, one.color]));
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot)); const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
const last = bucketCount - 1;
/** /**
* The bucket's busiest clients, then Other, then the total — eight named * The bucket's busiest clients, then Other, then the total — eight named
@@ -131,7 +145,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
value: formatCount(column[one.key]), value: formatCount(column[one.key]),
})); }));
return { return {
title: formatTime(column.ts), title: bucketTitle(column.ts, index === last),
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }], rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
}; };
} }
@@ -140,7 +154,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
<ChartRoot containerRef={containerRef}> <ChartRoot containerRef={containerRef}>
<svg <svg
role="img" role="img"
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`} aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series, the last still counting`}
width="100%" width="100%"
height={CHART_HEIGHT} height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`} viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -166,7 +180,10 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
columns.map((column, index) => ( columns.map((column, index) => (
<Group <Group
key={column.ts} key={column.ts}
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55} opacity={
(hovered.index === null || hovered.index === index ? 1 : 0.55) *
(index === last ? STILL_COUNTING_OPACITY : 1)
}
> >
{stacks.map((stack) => { {stacks.map((stack) => {
const bar = stack.bars[index]; const bar = stack.bars[index];
@@ -187,6 +204,17 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
)) ))
} }
</BarStack> </BarStack>
{totals[last] > 0 && (
<rect
data-still-counting=""
x={xScale(columns[last].ts) ?? plot.x}
y={yScale(totals[last])}
width={xScale.bandwidth()}
height={plot.bottom - yScale(totals[last])}
strokeDasharray={STILL_COUNTING_DASH}
{...stylex.props(styles.stillCounting)}
/>
)}
<HitBands plot={plot} centers={centers} onEnter={hovered.show} /> <HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg> </svg>
{hovered.index !== null && ( {hovered.index !== null && (
@@ -207,9 +235,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{columns.map((column) => ( {columns.map((column, index) => (
<tr key={column.ts}> <tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th> <th scope="row">{bucketTitle(column.ts, index === last)}</th>
{series.map((one) => ( {series.map((one) => (
<td key={one.key}>{formatCount(column[one.key])}</td> <td key={one.key}>{formatCount(column[one.key])}</td>
))} ))}
@@ -450,7 +450,7 @@ test("the legend names the two curves, and the table adds the remainder", () =>
); );
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent); const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Total queries", "Blocked"]); expect(legend).toEqual(["Total queries", "Blocked", "Still counting"]);
expect( expect(
within(screen.getByRole("table")) within(screen.getByRole("table"))
.getAllByRole("columnheader") .getAllByRole("columnheader")
@@ -470,3 +470,42 @@ test("the hidden table groups its counts like every other figure on the page", (
.map((cell) => cell.textContent), .map((cell) => cell.textContent),
).toEqual(["12,345", "1,234", "11,111"]); ).toEqual(["12,345", "1,234", "11,111"]);
}); });
/**
* The API ends every window at the end of the bucket that holds "now", so the
* last bucket is still filling, and a curve falling into it read as a collapse
* in traffic. The stretch into the last point is drawn dashed over a fainter
* fill, the settled curve stops where it starts, and every text says so.
*/
test("the curve into the last bucket is drawn as still counting", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 900, blocked: 90, cached: 0 },
{ ts: SINCE + 1800, queries: 800, blocked: 80, cached: 0 },
{ ts: SINCE + 3600, queries: 130, blocked: 13, cached: 0 },
])}
/>,
);
const counting = container.querySelector('g[data-series="queries"] g[data-still-counting]') as SVGGElement;
const [area, line] = Array.from(counting.querySelectorAll("path"));
expect(line.getAttribute("stroke-dasharray")).toBeTruthy();
expect(Number(area.getAttribute("fill-opacity"))).toBeLessThan(0.13);
expect(seriesPaths(container, "queries").line.getAttribute("stroke-dasharray")).toBeNull();
// The two clips meet at the second-to-last point, where the settled curve ends.
const middleX = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[3][0];
const clips = Array.from(container.querySelectorAll("defs rect"));
expect(clips).toHaveLength(2);
expect(clips[0].getAttribute("width")).toBe(clips[1].getAttribute("x"));
expect(Number(clips[0].getAttribute("width"))).toBeCloseTo(middleX, 0);
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
fireEvent.mouseOver(overlayRects(container)[2]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
expect(rows.at(-1)?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
expect(rows[0].textContent).toBe(formatTime(SINCE));
});
+51 -19
View File
@@ -6,8 +6,9 @@
* the reader is looking at. * the reader is looking at.
*/ */
import { useId } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { formatCount, formatTime } from "@/lib/format"; import { formatCount } from "@/lib/format";
import type { Bucket } from "@/lib/types"; import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
@@ -19,11 +20,15 @@ import {
ChartTooltip, ChartTooltip,
EmptyChart, EmptyChart,
HitBands, HitBands,
STILL_COUNTING_DASH,
STILL_COUNTING_OPACITY,
areaPath, areaPath,
bucketTitle,
labelTickValues, labelTickValues,
plotArea, plotArea,
pointScale, pointScale,
smoothPath, smoothPath,
svgId,
useActiveIndex, useActiveIndex,
useMeasuredWidth, useMeasuredWidth,
valueScale, valueScale,
@@ -75,9 +80,9 @@ function columnsOf(buckets: Bucket[]): Column[] {
})); }));
} }
function tooltipOf(column: Column): TooltipContent { function tooltipOf(column: Column, stillCounting: boolean): TooltipContent {
return { return {
title: formatTime(column.ts), title: bucketTitle(column.ts, stillCounting),
rows: [ rows: [
...SERIES.map((series) => ({ ...SERIES.map((series) => ({
key: series.key, key: series.key,
@@ -105,6 +110,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
// A hover survives a re-render only while it still names the same bucket at // A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it. // the same place: a poll that rolls the window, or a resize, retires it.
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`); const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
const clipId = svgId(useId());
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) { if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
return <EmptyChart containerRef={containerRef} />; return <EmptyChart containerRef={containerRef} />;
@@ -120,12 +126,16 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x); const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
const pointsOf = (key: SeriesKey): [number, number][] => const pointsOf = (key: SeriesKey): [number, number][] =>
columns.map((column, i) => [centers[i], yScale(column[key])]); columns.map((column, i) => [centers[i], yScale(column[key])]);
const last = columns.length - 1;
// The curve into the last point belongs to the bucket that is still
// filling, so everything right of the point before it is drawn as such.
const settledEdge = last > 0 ? centers[last - 1] : plot.x;
return ( return (
<ChartRoot containerRef={containerRef}> <ChartRoot containerRef={containerRef}>
<svg <svg
role="img" role="img"
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`} aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket, the last still counting`}
width="100%" width="100%"
height={CHART_HEIGHT} height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`} viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -139,23 +149,45 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
xTickValues={labelTickValues(timestamps, plot.width)} xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds} bucketSeconds={data.bucket_seconds}
/> />
<defs>
<clipPath id={`${clipId}-settled`}>
<rect x={0} y={0} width={settledEdge} height={CHART_HEIGHT} />
</clipPath>
<clipPath id={`${clipId}-counting`}>
<rect x={settledEdge} y={0} width={Math.max(0, width - settledEdge)} height={CHART_HEIGHT} />
</clipPath>
</defs>
{SERIES.map((series) => { {SERIES.map((series) => {
const points = pointsOf(series.key); const points = pointsOf(series.key);
const line = smoothPath(points, plot.y, plot.bottom); const line = smoothPath(points, plot.y, plot.bottom);
const area = areaPath(line, points, plot);
return ( return (
<g key={series.key} data-series={series.key}> <g key={series.key} data-series={series.key}>
<path <g clipPath={`url(#${clipId}-settled)`}>
d={areaPath(line, points, plot)} <path d={area} fill={series.color} fillOpacity={series.fillOpacity} />
fill={series.color} <path
fillOpacity={series.fillOpacity} d={line}
/> fill="none"
<path stroke={series.color}
d={line} strokeWidth={LINE_WIDTH}
fill="none" strokeLinejoin="round"
stroke={series.color} />
strokeWidth={LINE_WIDTH} </g>
strokeLinejoin="round" <g clipPath={`url(#${clipId}-counting)`} data-still-counting="">
/> <path
d={area}
fill={series.color}
fillOpacity={series.fillOpacity * STILL_COUNTING_OPACITY}
/>
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
strokeDasharray={STILL_COUNTING_DASH}
/>
</g>
</g> </g>
); );
})} })}
@@ -185,7 +217,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
{hovered.index !== null && ( {hovered.index !== null && (
<ChartTooltip <ChartTooltip
index={hovered.index} index={hovered.index}
content={tooltipOf(columns[hovered.index])} content={tooltipOf(columns[hovered.index], hovered.index === last)}
left={centers[hovered.index]} left={centers[hovered.index]}
top={yScale(columns[hovered.index].queries)} top={yScale(columns[hovered.index].queries)}
beside beside
@@ -206,9 +238,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{columns.map((column) => ( {columns.map((column, index) => (
<tr key={column.ts}> <tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th> <th scope="row">{bucketTitle(column.ts, index === last)}</th>
<td>{formatCount(column.queries)}</td> <td>{formatCount(column.queries)}</td>
<td>{formatCount(column.blocked)}</td> <td>{formatCount(column.blocked)}</td>
<td>{formatCount(column.allowed)}</td> <td>{formatCount(column.allowed)}</td>
+34 -1
View File
@@ -15,7 +15,7 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid"; import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale"; import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip"; import { TooltipWithBounds } from "@visx/tooltip";
import { formatBucketTime, formatCount } from "@/lib/format"; import { formatBucketTime, formatCount, formatTime } from "@/lib/format";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors, layers, metrics } from "@/ui/tokens.stylex"; import { colors, layers, metrics } from "@/ui/tokens.stylex";
@@ -405,6 +405,30 @@ export function StackSegment({
/** The amount one upper segment overlaps the one under it, in plot units. */ /** The amount one upper segment overlaps the one under it, in plot units. */
export const STACK_BLEED = 0.5; export const STACK_BLEED = 0.5;
/**
* The name for the last bucket of every window. The API ends a window at the
* end of the bucket that holds "now" (overview.zig `window`), so that bucket is
* still filling, and drawn like the others it reads as a collapse in traffic.
* Both charts draw it faint and dashed, and the legend says what that means, so
* the meaning does not depend on a hover.
*/
export const STILL_COUNTING = "Still counting";
/** Faint enough to read as unfinished beside the settled buckets, not as absent. */
export const STILL_COUNTING_OPACITY = 0.45;
export const STILL_COUNTING_DASH = "4 3";
/** A tooltip or table title for a bucket, naming the one that is still filling. */
export function bucketTitle(ts: number, stillCounting: boolean): string {
return stillCounting ? `${formatTime(ts)} · ${STILL_COUNTING.toLowerCase()}` : formatTime(ts);
}
/** `useId` output made safe inside an SVG `url(#…)` reference. */
export function svgId(reactId: string): string {
return reactId.replace(/[^A-Za-z0-9_-]/g, "");
}
/** /**
* The transparent hit targets: one per bucket, spanning the whole plot height so * The transparent hit targets: one per bucket, spanning the whole plot height so
* that pointing at the empty space above a short column or a low point still * that pointing at the empty space above a short column or a low point still
@@ -607,6 +631,11 @@ const legendStyles = stylex.create({
borderRadius: "0.125rem", borderRadius: "0.125rem",
}, },
swatchColor: (color: string) => ({ backgroundColor: color }), swatchColor: (color: string) => ({ backgroundColor: color }),
swatchCounting: {
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.textSecondary,
},
}); });
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */ /** The key under a chart: one swatch and one name per drawn series, in drawing order. */
@@ -622,6 +651,10 @@ export function ChartLegend({ entries }: { entries: { key: string; label: string
{entry.label} {entry.label}
</li> </li>
))} ))}
<li {...stylex.props(legendStyles.item)}>
<span aria-hidden="true" {...stylex.props(legendStyles.swatch, legendStyles.swatchCounting)} />
{STILL_COUNTING}
</li>
</ul> </ul>
); );
} }