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
@@ -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");
});
+34 -6
View File
@@ -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));
});
+51 -19
View File
@@ -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>
+34 -1
View File
@@ -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>
);
}