/** * 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 { StatsClients } 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: StatsClients, 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 }, ]; } /** One column: the timestamp plus one entry per series, keyed by the series key. */ type Column = { ts: number } & Record; export default function ClientChart({ data }: { data: StatsClients }) { 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 ; } 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 ( 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) => ( {stacks.map((stack) => { const bar = stack.bars[index]; return ( ); })} )) } {hovered.index !== null && ( )}
    {series.map((one) => (
  • ))}
{series.map((one) => ( ))} {columns.map((column) => ( {series.map((one) => ( ))} ))}
Queries per client per time bucket
Time {one.label}
{formatTime(column.ts)}{column[one.key]}
); }