fix(webapp): queue-detail charts — legends, at-limit concurrency colour, tooltip colour squares (TRI-12068)

Add legends to the Concurrency, Throughput and Scheduling-delay charts (item 8). Recolour the Concurrency running line above the limit with a gradient split so it's orange only at/over the limit, not on the way up (item 18). Rework the chart info tooltips to show inline colour swatches with the word "color" (item 10).
This commit is contained in:
James Ritchie
2026-07-23 15:18:52 +01:00
parent 2fa69106ba
commit 84f9f5e6eb
2 changed files with 99 additions and 8 deletions
@@ -104,6 +104,20 @@ type QueueMetricChartProps = {
* are config values that existed all along, so carry the first value backward instead.
*/
carryBackfill?: string[];
/** Show the series legend below the chart (use for multi-series charts). */
showLegend?: boolean;
/**
* Recolour a series' stroke above a threshold with a gradient split (colour only above the
* line). `value` sets a constant threshold; `valueFromSeries` reads a (roughly constant)
* threshold off another series — e.g. the concurrency limit. `series` targets which line is
* recoloured; the others keep their own colour.
*/
thresholdStroke?: {
aboveColor: string;
series?: string;
value?: number;
valueFromSeries?: string;
};
};
// Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel.
@@ -118,6 +132,8 @@ export function QueueMetricChart({
defaultPeriod,
warningOverlay,
carryBackfill,
showLegend,
thresholdStroke,
}: QueueMetricChartProps) {
const { rows, showLoading, failed } = useQueueMetric(query, {
ids,
@@ -163,6 +179,26 @@ export function QueueMetricChart({
[data]
);
// Resolve the threshold value: a constant, or the max of another series (e.g. the limit line,
// which is effectively constant). A gradient split then colours the target series only above it.
// `valueFromSeries` targets integer-count series (concurrency limit), so split half a unit below
// the limit — that way the line renders warning *at or above* the limit (saturated), matching
// "turns yellow at the limit", rather than only when it strictly exceeds it.
const resolvedThresholdStroke = useMemo(() => {
if (!thresholdStroke) return undefined;
let value = thresholdStroke.value;
if (value == null && thresholdStroke.valueFromSeries) {
let max = -Infinity;
for (const p of data) {
const v = Number(p[thresholdStroke.valueFromSeries]);
if (Number.isFinite(v) && v > max) max = v;
}
value = max > 0 ? max - 0.5 : undefined;
}
if (value == null || !Number.isFinite(value)) return undefined;
return { value, aboveColor: thresholdStroke.aboveColor, series: thresholdStroke.series };
}, [thresholdStroke, data]);
const state: ChartState = showLoading ? "loading" : failed ? "invalid" : undefined;
return (
@@ -173,6 +209,7 @@ export function QueueMetricChart({
series={series.map((s) => s.key)}
state={state}
fillContainer
showLegend={showLegend}
>
<Chart.Line
lineType="monotone"
@@ -181,6 +218,7 @@ export function QueueMetricChart({
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={valueFormat}
warningOverlay={warningOverlay}
thresholdStroke={resolvedThresholdStroke}
/>
</Chart.Root>
);
@@ -348,6 +348,17 @@ export default function Page() {
);
}
// Inline colour swatch for tooltip copy — matches the chart legend swatch (rounded-[2px]) and is
// nudged up 1px so it sits on the text baseline.
function ColorSwatch({ color }: { color: string }) {
return (
<span
className="mx-0.5 inline-block size-2.5 -translate-y-px rounded-[2px] align-middle"
style={{ backgroundColor: color }}
/>
);
}
function OverviewCharts({
ids,
timeRange,
@@ -363,7 +374,14 @@ function OverviewCharts({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<QueueDetailChartCard
title="Concurrency"
info="How many runs are going at once (purple) versus the queue's limit (grey). Turns yellow at the limit."
info={
<>
How many runs are going at once (<ColorSwatch color={COLORS.running} /> color) versus
the queue's limit (<ColorSwatch color={COLORS.limit} /> color). Turns{" "}
<ColorSwatch color="var(--color-warning)" /> color when it reaches the limit.
</>
}
showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_running) AS running, max(max_limit) AS limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -375,15 +393,26 @@ function OverviewCharts({
{ key: "limit", label: "Limit", color: COLORS.limit },
{ key: "running", label: "Running", color: COLORS.running },
]}
// Recolour Running warning where it reaches the limit (saturated), matching the tooltip.
warningOverlay={{ series: "running", atOrAbove: "limit" }}
// Recolour Running above the limit line with a gradient split, so it's orange only where
// it's actually over the limit — not on the way up. The threshold reads off the (roughly
// constant) limit series.
thresholdStroke={{
series: "running",
valueFromSeries: "limit",
aboveColor: "var(--color-warning)",
}}
// The limit is a config value emitted only while the queue is active; back-fill its
// leading zeros so the reference line doesn't start with a false 0→limit step.
carryBackfill={["limit"]}
/>
<QueueDetailChartCard
title="Queue depth"
info="How many runs are waiting in this queue, over time."
info={
<>
How many runs are waiting in this queue over time (
<ColorSwatch color={COLORS.queued} /> color).
</>
}
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -394,7 +423,14 @@ function OverviewCharts({
/>
<QueueDetailChartCard
title="Throughput"
info="Runs arriving (Enqueued, grey) versus starting (Started, purple). Turns yellow when Started falls behind."
info={
<>
Runs arriving (Enqueued, <ColorSwatch color={COLORS.limit} /> color) versus starting
(Started, <ColorSwatch color={COLORS.running} /> color). Turns{" "}
<ColorSwatch color="var(--color-warning)" /> color when Started falls behind.
</>
}
showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t,\n deltaSumTimestampMerge(enqueue_delta) AS enqueued,\n deltaSumTimestampMerge(started_delta) AS started\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -411,7 +447,14 @@ function OverviewCharts({
/>
<QueueDetailChartCard
title="Scheduling delay"
info="How long runs wait before they start (p50/p95/p99)."
info={
<>
How long runs wait before they start: p50 (<ColorSwatch color={COLORS.p50} /> color),
p95 (<ColorSwatch color={COLORS.p95} /> color), and p99 (
<ColorSwatch color={COLORS.p99} /> color).
</>
}
showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[1]) AS p50,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[4]) AS p99\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -427,7 +470,12 @@ function OverviewCharts({
/>
<QueueDetailChartCard
title="Throttled"
info="How often runs were held back by a limit."
info={
<>
How often runs were held back by a limit (<ColorSwatch color={COLORS.throttled} />{" "}
color).
</>
}
className="aspect-[2/1] sm:col-span-2 sm:aspect-[4/1]"
query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -844,7 +892,12 @@ function KeyDrilldown({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<QueueDetailChartCard
title={`Key ${keyName}: backlog and running`}
info="This key: waiting (Queued, purple) vs running (grey)."
info={
<>
This key: waiting (Queued, <ColorSwatch color={COLORS.queued} /> color) vs running (
<ColorSwatch color={COLORS.limit} /> color).
</>
}
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_queued) AS queued, max(max_running) AS running\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
fillGaps