milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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 the timeseries endpoint's own bucket alignment, 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 { useEffect, useRef, useState } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
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 { layoutStacked } from "./chartLayout";
|
||||
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";
|
||||
|
||||
const CHART_HEIGHT = 240;
|
||||
const FALLBACK_WIDTH = 640;
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: CHART_HEIGHT,
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.borderStrong,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
root: {
|
||||
position: "relative",
|
||||
},
|
||||
gridLine: {
|
||||
stroke: colors.border,
|
||||
},
|
||||
axisLine: {
|
||||
stroke: colors.borderStrong,
|
||||
},
|
||||
axisLabel: {
|
||||
fill: colors.textMuted,
|
||||
fontSize: "10px",
|
||||
},
|
||||
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||
segment: {
|
||||
stroke: colors.surface,
|
||||
},
|
||||
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 }),
|
||||
});
|
||||
|
||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width];
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||
|
||||
function formatTick(ts: number, bucketSeconds: number): string {
|
||||
const date = new Date(ts * 1000);
|
||||
if (bucketSeconds >= 86_400) {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
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 always present: the response always carries the series, and a
|
||||
* legend that dropped it on a quiet period would make the reader think the
|
||||
* chart's clients were all of them.
|
||||
*/
|
||||
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,
|
||||
}));
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
}
|
||||
|
||||
export default function ClientChart({ data }: { data: StatsClients }) {
|
||||
const [containerRef, measuredWidth] = useContainerWidth();
|
||||
const names = useClientNames();
|
||||
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||
const series = seriesOf(data, names);
|
||||
const bucketCount = data.other.length;
|
||||
|
||||
if (bucketCount === 0) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = Array.from({ length: bucketCount }, (_, i) => ({
|
||||
ts: data.since + i * data.bucket_seconds,
|
||||
values: series.map((one) => one.buckets[i] ?? 0),
|
||||
}));
|
||||
if (columns.every((column) => column.values.every((value) => value === 0))) {
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||
No queries in this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layout = layoutStacked(columns, width, CHART_HEIGHT);
|
||||
const baseline = layout.plot.y + layout.plot.height;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} {...stylex.props(styles.root)}>
|
||||
<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}`}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick.value}>
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
{...stylex.props(styles.gridLine)}
|
||||
/>
|
||||
<text
|
||||
x={layout.plot.x - 6}
|
||||
y={tick.y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||
>
|
||||
{compact.format(tick.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={layout.plot.x}
|
||||
x2={layout.plot.x + layout.plot.width}
|
||||
y1={baseline}
|
||||
y2={baseline}
|
||||
{...stylex.props(styles.axisLine)}
|
||||
/>
|
||||
{layout.xTicks.map((tick) => (
|
||||
<text
|
||||
key={tick.ts}
|
||||
x={tick.x}
|
||||
y={baseline + 14}
|
||||
textAnchor="middle"
|
||||
{...stylex.props(styles.axisLabel)}
|
||||
>
|
||||
{formatTick(tick.ts, data.bucket_seconds)}
|
||||
</text>
|
||||
))}
|
||||
{layout.columns.map((column) => (
|
||||
<g key={column.ts}>
|
||||
{column.segments.map((rect, index) =>
|
||||
rect.height <= 0 ? null : (
|
||||
<rect
|
||||
key={series[index].key}
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
fill={series[index].color}
|
||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||
{...stylex.props(styles.segment)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<rect
|
||||
x={column.slot.x}
|
||||
y={column.slot.y}
|
||||
width={column.slot.width}
|
||||
height={column.slot.height}
|
||||
fill="transparent"
|
||||
>
|
||||
<title>
|
||||
{`${formatTime(column.ts)}: ${column.total} ${column.total === 1 ? "query" : "queries"}`}
|
||||
</title>
|
||||
</rect>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<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>
|
||||
{column.values.map((value, index) => (
|
||||
<td key={series[index].key}>{value}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user