Skip to content

Commit 4dc869b

Browse files
committed
feat(webapp): surface queue metrics on task detail and run inspector
Behind the per-org queue metrics UI flag: the task detail page links its queue to the queue detail view and shows live queued/running counts, delay p95, peak backlog, and a queue backlog chart; the run inspector links the queue and concurrency key, and queued, delayed, and pending-version runs get a "Waiting in queue" block with an at-limit callout and per-key counts. Adds optional concurrency-key params to the run engine queue reads.
1 parent 6a50ab7 commit 4dc869b

8 files changed

Lines changed: 698 additions & 172 deletions

File tree

.server-changes/queue-metrics-dashboard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

6-
Queue metrics and health on the Queues page: per-queue depth, throughput, concurrency, throttling, and scheduling-delay charts, plus a per-queue detail view. Off by default; enabled per organization.
6+
Queue metrics and health on the Queues page: per-queue depth, throughput, concurrency, throttling, and scheduling-delay charts, plus a per-queue detail view, live queue stats and a backlog chart on the task detail page, and a waiting-in-queue explainer in the run inspector for runs that have not started yet. Off by default; enabled per organization.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { useMemo } from "react";
2+
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
3+
import {
4+
Chart,
5+
type ChartConfig,
6+
type ChartState,
7+
} from "~/components/primitives/charts/ChartCompound";
8+
import { ChartCard } from "~/components/primitives/charts/ChartCard";
9+
import {
10+
useMetricResourceQuery,
11+
type MetricResourceTimeRange,
12+
} from "~/hooks/useMetricResourceQuery";
13+
import { Paragraph } from "~/components/primitives/Paragraph";
14+
import { useSearchParams } from "~/hooks/useSearchParam";
15+
import { cn } from "~/utils/cn";
16+
17+
// Shared building blocks for queue-metric UI (queue detail page, task detail page,
18+
// run inspector). All CH-derived data is fetched client-side through useQueueMetric
19+
// so pages render instantly; loaders only supply live counts and identifiers.
20+
21+
export const QUEUE_METRIC_COLORS = {
22+
running: "#6366F1",
23+
limit: "#4D525B",
24+
queued: "#A78BFA",
25+
p50: "#22D3EE",
26+
p95: "#F59E0B",
27+
p99: "#EF4444",
28+
throttled: "#F59E0B",
29+
ckKeys: "#34D399",
30+
ckWait: "#F59E0B",
31+
};
32+
33+
export const QUEUE_METRICS_DEFAULT_PERIOD = "1d";
34+
35+
export type QueueMetricIds = {
36+
organizationId: string;
37+
projectId: string;
38+
environmentId: string;
39+
};
40+
41+
export type QueueMetricTimeRange = MetricResourceTimeRange;
42+
43+
export function useQueueMetric(
44+
query: string,
45+
opts: {
46+
ids: QueueMetricIds;
47+
timeRange: QueueMetricTimeRange;
48+
queueName: string;
49+
fillGaps?: boolean;
50+
/** Match the host page's TimeFilter default (e.g. "7d" on task detail). */
51+
defaultPeriod?: string;
52+
}
53+
) {
54+
return useMetricResourceQuery(query, {
55+
...opts.ids,
56+
timeRange: opts.timeRange,
57+
defaultPeriod: opts.defaultPeriod ?? QUEUE_METRICS_DEFAULT_PERIOD,
58+
queues: [opts.queueName],
59+
fillGaps: opts.fillGaps,
60+
});
61+
}
62+
63+
export function toNumber(value: number | string | null | undefined): number {
64+
const n = typeof value === "number" ? value : Number(value);
65+
return Number.isFinite(n) ? n : 0;
66+
}
67+
68+
export function clickhouseTimeToMs(value: unknown): number {
69+
const s = String(value).replace(" ", "T");
70+
return Date.parse(s.endsWith("Z") ? s : `${s}Z`);
71+
}
72+
73+
export function formatWaitMs(ms: number): string {
74+
if (ms < 1000) return `${Math.round(ms)}ms`;
75+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
76+
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(1)}m`;
77+
return `${(ms / 3_600_000).toFixed(1)}h`;
78+
}
79+
80+
export type QueueMetricSeriesConfig = { key: string; label: string; color: string };
81+
82+
export function QueueMetricChartCard({
83+
title,
84+
query,
85+
series,
86+
ids,
87+
timeRange,
88+
queueName,
89+
valueFormat,
90+
fillGaps,
91+
defaultPeriod,
92+
className,
93+
}: {
94+
title: string;
95+
query: string;
96+
series: QueueMetricSeriesConfig[];
97+
ids: QueueMetricIds;
98+
timeRange: QueueMetricTimeRange;
99+
queueName: string;
100+
valueFormat?: (value: number) => string;
101+
fillGaps?: boolean;
102+
defaultPeriod?: string;
103+
className?: string;
104+
}) {
105+
const { rows, showLoading, failed } = useQueueMetric(query, {
106+
ids,
107+
timeRange,
108+
queueName,
109+
fillGaps,
110+
defaultPeriod,
111+
});
112+
113+
const data = useMemo(() => {
114+
return rows
115+
.map((r) => {
116+
const point: { bucket: number } & Record<string, number> = {
117+
bucket: clickhouseTimeToMs(r.t),
118+
};
119+
for (const s of series) point[s.key] = toNumber(r[s.key]);
120+
return point;
121+
})
122+
.filter((p) => Number.isFinite(p.bucket));
123+
}, [rows, series]);
124+
125+
const chartConfig = useMemo(() => {
126+
const cfg: ChartConfig = {};
127+
for (const s of series) cfg[s.key] = { label: s.label, color: s.color };
128+
return cfg;
129+
}, [series]);
130+
131+
const { tickFormatter, tooltipLabelFormatter } = useMemo(
132+
() => buildActivityTimeAxis(data),
133+
[data]
134+
);
135+
136+
const state: ChartState = showLoading ? "loading" : failed ? "invalid" : undefined;
137+
138+
return (
139+
<div className={className ?? "h-64"}>
140+
<ChartCard title={title}>
141+
<Chart.Root
142+
config={chartConfig}
143+
data={data}
144+
dataKey="bucket"
145+
series={series.map((s) => s.key)}
146+
state={state}
147+
fillContainer
148+
>
149+
<Chart.Line
150+
lineType="monotone"
151+
xAxisProps={{ tickFormatter }}
152+
yAxisProps={valueFormat ? { tickFormatter: (v: number) => valueFormat(v) } : undefined}
153+
tooltipLabelFormatter={tooltipLabelFormatter}
154+
tooltipValueFormatter={valueFormat}
155+
/>
156+
</Chart.Root>
157+
</ChartCard>
158+
</div>
159+
);
160+
}
161+
162+
export type QueueLiveCounts = { queued: number; running: number };
163+
164+
// Compact two-line stat block for task detail sidebars: live counts from the loader,
165+
// then delay p95 + peak backlog over the page's TimeFilter range.
166+
export function QueueSidebarStats({
167+
live,
168+
ids,
169+
queueName,
170+
defaultPeriod,
171+
}: {
172+
live: QueueLiveCounts;
173+
ids: QueueMetricIds;
174+
queueName: string;
175+
defaultPeriod?: string;
176+
}) {
177+
const { value } = useSearchParams();
178+
const timeRange: QueueMetricTimeRange = {
179+
period: value("period") ?? null,
180+
from: value("from") ?? null,
181+
to: value("to") ?? null,
182+
};
183+
184+
const { rows, showLoading } = useQueueMetric(
185+
`SELECT max(max_queued) AS peak_queued,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS worst_p95\nFROM queue_metrics`,
186+
{ ids, timeRange, queueName, defaultPeriod }
187+
);
188+
const row = rows[0];
189+
const worstP95 = row ? toNumber(row.worst_p95) : 0;
190+
const peakQueued = row ? toNumber(row.peak_queued) : 0;
191+
192+
return (
193+
<>
194+
<Paragraph variant="extra-small" className="tabular-nums text-text-dimmed">
195+
Queued now {live.queued.toLocaleString()} · Running now {live.running.toLocaleString()}
196+
</Paragraph>
197+
<Paragraph variant="extra-small" className="tabular-nums text-text-dimmed">
198+
{showLoading || !row
199+
? "…"
200+
: `Delay p95 ${worstP95 > 0 ? formatWaitMs(worstP95) : "–"} · Peak backlog ${peakQueued.toLocaleString()}`}
201+
</Paragraph>
202+
</>
203+
);
204+
}
205+
206+
export function QueueMetricStat({
207+
label,
208+
value,
209+
className,
210+
loading,
211+
}: {
212+
label: string;
213+
value: string;
214+
className?: string;
215+
loading?: boolean;
216+
}) {
217+
return (
218+
<div className="rounded-sm border border-grid-dimmed bg-background-bright px-3 py-2">
219+
<div className="text-xs text-text-dimmed">{label}</div>
220+
{loading ? (
221+
<div className="mt-1 h-6 w-12 animate-pulse rounded bg-grid-bright/50" />
222+
) : (
223+
<div className={cn("text-2xl tabular-nums text-text-bright", className)}>{value}</div>
224+
)}
225+
</div>
226+
);
227+
}

0 commit comments

Comments
 (0)