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:
@@ -1,4 +1,5 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
@@ -7,6 +8,7 @@ import { createAppRouter } from "@/routes";
|
||||
import type { QueryDetail } from "@/lib/types";
|
||||
import { provenance } from "@/features/provenance/provenanceFixture";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
|
||||
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
||||
return { id, ...provenance(sections) };
|
||||
@@ -282,24 +284,40 @@ async function relatedText(expected: string) {
|
||||
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/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
|
||||
renderDetail(12);
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
|
||||
expect(factValue("Client")).toBe("192.0.2.12");
|
||||
await waitFor(() => expect(factValue("Client")).toBe("Kids iPad (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/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
|
||||
renderDetail(13);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
|
||||
expect(factValue("Client")).toBe("192.0.2.13");
|
||||
await waitFor(() => expect(factValue("Client")).toBe("printer.lan (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 () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { PolicyReason, Provenance } from "@/lib/types";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { ClientName, useClientNames } from "@/features/clients/clientNames";
|
||||
import {
|
||||
policyActionLabel,
|
||||
policyReasonLabel,
|
||||
@@ -178,7 +178,6 @@ interface Props {
|
||||
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||
const { request, group, policy, rewrites, route, response } = provenance;
|
||||
const clientNames = useClientNames();
|
||||
const currentClient = clientLabel(request.client, clientNames);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -192,7 +191,8 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<div {...stylex.props(styles.record)}>
|
||||
<p {...stylex.props(styles.recordNote)}>
|
||||
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 &&
|
||||
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||
</p>
|
||||
@@ -203,7 +203,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<Mono>{request.domain}</Mono>
|
||||
</Fact>
|
||||
<Fact label="Client">
|
||||
<Mono>{request.client}</Mono>
|
||||
<ClientName ip={request.client} names={clientNames} />
|
||||
</Fact>
|
||||
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
|
||||
<Fact label="Class">{qclassName(request.qclass)}</Fact>
|
||||
@@ -284,20 +284,6 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
|
||||
<p {...stylex.props(styles.relatedNote)}>
|
||||
These read the current configuration, which may no longer be the one that decided this query.
|
||||
</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>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createQueryClient } from "@/lib/queryClient";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import ClientChart, { type ClientChartData } from "./ClientChart";
|
||||
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
|
||||
import { STILL_COUNTING_OPACITY } from "./chartKit";
|
||||
|
||||
const SINCE = 1_700_000_000;
|
||||
const BUCKET = 1800;
|
||||
@@ -73,7 +74,10 @@ test("the columns are timestamped from this response's own window", () => {
|
||||
render(TWO_BUCKETS);
|
||||
|
||||
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"))
|
||||
.filter((fill) => fill !== "transparent");
|
||||
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 });
|
||||
|
||||
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]"));
|
||||
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. */
|
||||
@@ -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));
|
||||
|
||||
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);
|
||||
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]"));
|
||||
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([
|
||||
"192.0.2.30",
|
||||
"Other",
|
||||
"Still counting",
|
||||
]);
|
||||
expect(
|
||||
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]));
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Group } from "@visx/group";
|
||||
import { BarStack } from "@visx/shape";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatCount } 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 {
|
||||
CHART_HEIGHT,
|
||||
@@ -25,8 +26,11 @@ import {
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STACK_BLEED,
|
||||
STILL_COUNTING_DASH,
|
||||
STILL_COUNTING_OPACITY,
|
||||
StackSegment,
|
||||
bandScale,
|
||||
bucketTitle,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
slotCenter,
|
||||
@@ -38,6 +42,15 @@ import {
|
||||
} from "./chartKit";
|
||||
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. */
|
||||
const TOOLTIP_CLIENTS = 4;
|
||||
|
||||
@@ -110,6 +123,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
const yTicks = valueTicks(yScale);
|
||||
const colorOf = new Map(series.map((one) => [one.key, one.color]));
|
||||
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
|
||||
@@ -131,7 +145,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
value: formatCount(column[one.key]),
|
||||
}));
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
title: bucketTitle(column.ts, index === last),
|
||||
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}>
|
||||
<svg
|
||||
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%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -166,7 +180,10 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
columns.map((column, index) => (
|
||||
<Group
|
||||
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) => {
|
||||
const bar = stack.bars[index];
|
||||
@@ -187,6 +204,17 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
))
|
||||
}
|
||||
</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} />
|
||||
</svg>
|
||||
{hovered.index !== null && (
|
||||
@@ -207,9 +235,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
{columns.map((column, index) => (
|
||||
<tr key={column.ts}>
|
||||
<th scope="row">{formatTime(column.ts)}</th>
|
||||
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
|
||||
{series.map((one) => (
|
||||
<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);
|
||||
expect(legend).toEqual(["Total queries", "Blocked"]);
|
||||
expect(legend).toEqual(["Total queries", "Blocked", "Still counting"]);
|
||||
expect(
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("columnheader")
|
||||
@@ -470,3 +470,42 @@ test("the hidden table groups its counts like every other figure on the page", (
|
||||
.map((cell) => cell.textContent),
|
||||
).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));
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* the reader is looking at.
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatCount, formatTime } from "@/lib/format";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import type { Bucket } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -19,11 +20,15 @@ import {
|
||||
ChartTooltip,
|
||||
EmptyChart,
|
||||
HitBands,
|
||||
STILL_COUNTING_DASH,
|
||||
STILL_COUNTING_OPACITY,
|
||||
areaPath,
|
||||
bucketTitle,
|
||||
labelTickValues,
|
||||
plotArea,
|
||||
pointScale,
|
||||
smoothPath,
|
||||
svgId,
|
||||
useActiveIndex,
|
||||
useMeasuredWidth,
|
||||
valueScale,
|
||||
@@ -75,9 +80,9 @@ function columnsOf(buckets: Bucket[]): Column[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function tooltipOf(column: Column): TooltipContent {
|
||||
function tooltipOf(column: Column, stillCounting: boolean): TooltipContent {
|
||||
return {
|
||||
title: formatTime(column.ts),
|
||||
title: bucketTitle(column.ts, stillCounting),
|
||||
rows: [
|
||||
...SERIES.map((series) => ({
|
||||
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
|
||||
// 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 clipId = svgId(useId());
|
||||
|
||||
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
|
||||
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 pointsOf = (key: SeriesKey): [number, number][] =>
|
||||
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 (
|
||||
<ChartRoot containerRef={containerRef}>
|
||||
<svg
|
||||
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%"
|
||||
height={CHART_HEIGHT}
|
||||
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||
@@ -139,23 +149,45 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
xTickValues={labelTickValues(timestamps, plot.width)}
|
||||
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) => {
|
||||
const points = pointsOf(series.key);
|
||||
const line = smoothPath(points, plot.y, plot.bottom);
|
||||
const area = areaPath(line, points, plot);
|
||||
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 clipPath={`url(#${clipId}-settled)`}>
|
||||
<path d={area} fill={series.color} fillOpacity={series.fillOpacity} />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={series.color}
|
||||
strokeWidth={LINE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -185,7 +217,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
{hovered.index !== null && (
|
||||
<ChartTooltip
|
||||
index={hovered.index}
|
||||
content={tooltipOf(columns[hovered.index])}
|
||||
content={tooltipOf(columns[hovered.index], hovered.index === last)}
|
||||
left={centers[hovered.index]}
|
||||
top={yScale(columns[hovered.index].queries)}
|
||||
beside
|
||||
@@ -206,9 +238,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((column) => (
|
||||
{columns.map((column, index) => (
|
||||
<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.blocked)}</td>
|
||||
<td>{formatCount(column.allowed)}</td>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
|
||||
import { GridRows } from "@visx/grid";
|
||||
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
|
||||
import { TooltipWithBounds } from "@visx/tooltip";
|
||||
import { formatBucketTime, formatCount } from "@/lib/format";
|
||||
import { formatBucketTime, formatCount, formatTime } from "@/lib/format";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
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. */
|
||||
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
|
||||
* 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",
|
||||
},
|
||||
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. */
|
||||
@@ -622,6 +651,10 @@ export function ChartLegend({ entries }: { entries: { key: string; label: string
|
||||
{entry.label}
|
||||
</li>
|
||||
))}
|
||||
<li {...stylex.props(legendStyles.item)}>
|
||||
<span aria-hidden="true" {...stylex.props(legendStyles.swatch, legendStyles.swatchCounting)} />
|
||||
{STILL_COUNTING}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user