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
+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>