Files
nxdns/admin/src/features/overview/ClientChart.tsx
T

256 lines
7.8 KiB
TypeScript

/**
* Client activity over the same window as the query-volume chart: one stacked
* 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.
*/
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { 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,
ChartRoot,
ChartTooltip,
EmptyChart,
StackSegment,
bandScale,
labelTickValues,
plotArea,
slotCenter,
useActiveIndex,
useMeasuredWidth,
valueScale,
valueTicks,
type TooltipContent,
} from "./chartKit";
import { OTHER_KEY, clientKey, 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 }),
});
interface Series {
key: string;
label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string;
buckets: number[];
}
/**
* "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.
*/
function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
const named = data.clients.map((client) => ({
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.
label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)),
buckets: client.buckets,
}));
if (data.other.every((count) => count === 0)) return named;
return [
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
}
/**
* The slice of the Overview body this chart draws. Declared here rather than
* taken whole, so what the chart reads is stated where it is read.
*/
export interface ClientChartData {
since: number;
bucket_seconds: number;
clients: OverviewClientSeries[];
other: number[];
}
/** One column: the timestamp plus one entry per series, keyed by the series key. */
type Column = { ts: number } & Record<string, number>;
export default function ClientChart({ data }: { data: ClientChartData }) {
const [containerRef, width] = useMeasuredWidth();
// 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.other.length}:${width}`);
const names = useClientNames();
const series = seriesOf(data, names);
const bucketCount = data.other.length;
const columns: Column[] = Array.from({ length: bucketCount }, (_, i) => {
const column: Column = { ts: data.since + i * data.bucket_seconds };
for (const one of series) column[one.key] = one.buckets[i] ?? 0;
return column;
});
if (bucketCount === 0 || columns.every((column) => series.every((one) => column[one.key] === 0))) {
return <EmptyChart containerRef={containerRef} />;
}
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 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
// the scale.
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]));
function tooltipOf(index: number): TooltipContent {
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]),
})),
],
};
}
return (
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Client activity over time, ${bucketCount} buckets, ${series.length} series`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
onMouseLeave={hovered.clear}
>
<ChartFrame
plot={plot}
yScale={yScale}
yTicks={yTicks}
xScale={xScale}
xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds}
/>
<BarStack<Column, string>
data={columns}
keys={series.map((one) => one.key)}
x={(column) => column.ts}
xScale={xScale}
yScale={yScale}
color={(key) => colorOf.get(key) ?? seriesColor(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} />
</svg>
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(hovered.index)}
left={slotCenter(xScale, timestamps[hovered.index], plot)}
/>
)}
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
))}
</ul>
<div {...stylex.props(shared.srOnly)}>
<table>
<caption>Queries per client per time bucket</caption>
<thead>
<tr>
<th scope="col">Time</th>
{series.map((one) => (
<th key={one.key} scope="col">
{one.label}
</th>
))}
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
{series.map((one) => (
<td key={one.key}>{column[one.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</ChartRoot>
);
}