Skip to content

Commit aa44795

Browse files
committed
feat(webapp): move concurrency-key metrics to a tab on the queue detail page
The queue detail page splits into Overview (the existing concurrency, backlog, delay, and throttle charts) and a Concurrency keys tab that holds all per-key content. The tab only appears for queues with key activity. Inside it, the grouped per-key charts now lead (backlog by key plus a new throughput-by-key chart that makes fair-share visible), followed by the two whole-queue health charts retitled to say what they aggregate (keys with queued runs count, most-starved key wait), then the key table and drill-down.
1 parent 3c64f78 commit aa44795

1 file changed

Lines changed: 152 additions & 90 deletions

File tree

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx

Lines changed: 152 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
TableHeaderCell,
2828
TableRow,
2929
} from "~/components/primitives/Table";
30+
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
3031
import { engine } from "~/v3/runEngine.server";
3132
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
3233
import { useSearchParams } from "~/hooks/useSearchParam";
@@ -114,13 +115,26 @@ export default function Page() {
114115
const plan = useCurrentPlan();
115116
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
116117

117-
const { value } = useSearchParams();
118+
const { value, replace } = useSearchParams();
118119
const timeRange: TimeRangeParams = {
119120
period: value("period") ?? null,
120121
from: value("from") ?? null,
121122
to: value("to") ?? null,
122123
};
123124

125+
// The Concurrency keys tab exists only for queues with key activity: live keys in the
126+
// ckIndex, or nonzero CK history in the selected range (one cached scalar query decides).
127+
const { rows: gateRows, showLoading: gateLoading } = useQueueMetric(
128+
`SELECT max(max_ck_backlogged) AS peak_keys, max(max_ck_wait_ms) AS peak_wait\nFROM queue_metrics`,
129+
{ ids, timeRange, queueName: fullName }
130+
);
131+
const gateRow = gateRows[0];
132+
const hasHistory = gateRow
133+
? toNumber(gateRow.peak_keys) > 0 || toNumber(gateRow.peak_wait) > 0
134+
: false;
135+
const showKeysTab = ckBreakdown.keys.length > 0 || (!gateLoading && hasHistory);
136+
const view = value("view") === "keys" && showKeysTab ? "keys" : "overview";
137+
124138
return (
125139
<PageContainer>
126140
<NavBar>
@@ -145,63 +159,101 @@ export default function Page() {
145159
/>
146160
</div>
147161

148-
<QueueDetailChartCard
149-
title="Concurrency"
150-
query={`SELECT timeBucket() AS t, max(max_running) AS running, max(max_limit) AS limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
151-
fillGaps
152-
ids={ids}
153-
timeRange={timeRange}
154-
queueName={fullName}
155-
series={[
156-
{ key: "running", label: "Running", color: COLORS.running },
157-
{ key: "limit", label: "Limit", color: COLORS.limit },
158-
]}
159-
/>
160-
<QueueDetailChartCard
161-
title="Queue depth (backlog)"
162-
query={`SELECT timeBucket() AS t, max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
163-
fillGaps
164-
ids={ids}
165-
timeRange={timeRange}
166-
queueName={fullName}
167-
series={[{ key: "queued", label: "Queued", color: COLORS.queued }]}
168-
/>
169-
<QueueDetailChartCard
170-
title="Scheduling delay"
171-
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`}
172-
fillGaps
173-
ids={ids}
174-
timeRange={timeRange}
175-
queueName={fullName}
176-
valueFormat={formatWaitMs}
177-
series={[
178-
{ key: "p50", label: "p50", color: COLORS.p50 },
179-
{ key: "p95", label: "p95", color: COLORS.p95 },
180-
{ key: "p99", label: "p99", color: COLORS.p99 },
181-
]}
182-
/>
183-
<QueueDetailChartCard
184-
title="Throttled buckets"
185-
query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
186-
fillGaps
187-
ids={ids}
188-
timeRange={timeRange}
189-
queueName={fullName}
190-
series={[{ key: "throttled", label: "Throttled", color: COLORS.throttled }]}
191-
/>
192-
<ConcurrencyKeysSection
193-
breakdown={ckBreakdown}
194-
loadedAt={loadedAt}
195-
ids={ids}
196-
timeRange={timeRange}
197-
queueName={fullName}
198-
/>
162+
{showKeysTab && (
163+
<TabContainer>
164+
<TabButton
165+
isActive={view === "overview"}
166+
layoutId="queue-detail-view"
167+
onClick={() => replace({ view: undefined, key: undefined })}
168+
>
169+
Overview
170+
</TabButton>
171+
<TabButton
172+
isActive={view === "keys"}
173+
layoutId="queue-detail-view"
174+
onClick={() => replace({ view: "keys" })}
175+
>
176+
Concurrency keys
177+
</TabButton>
178+
</TabContainer>
179+
)}
180+
181+
{view === "keys" ? (
182+
<ConcurrencyKeysView
183+
breakdown={ckBreakdown}
184+
loadedAt={loadedAt}
185+
ids={ids}
186+
timeRange={timeRange}
187+
queueName={fullName}
188+
/>
189+
) : (
190+
<OverviewCharts ids={ids} timeRange={timeRange} queueName={fullName} />
191+
)}
199192
</div>
200193
</PageBody>
201194
</PageContainer>
202195
);
203196
}
204197

198+
function OverviewCharts({
199+
ids,
200+
timeRange,
201+
queueName,
202+
}: {
203+
ids: Ids;
204+
timeRange: TimeRangeParams;
205+
queueName: string;
206+
}) {
207+
return (
208+
<>
209+
<QueueDetailChartCard
210+
title="Concurrency"
211+
query={`SELECT timeBucket() AS t, max(max_running) AS running, max(max_limit) AS limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
212+
fillGaps
213+
ids={ids}
214+
timeRange={timeRange}
215+
queueName={queueName}
216+
series={[
217+
{ key: "running", label: "Running", color: COLORS.running },
218+
{ key: "limit", label: "Limit", color: COLORS.limit },
219+
]}
220+
/>
221+
<QueueDetailChartCard
222+
title="Queue depth (backlog)"
223+
query={`SELECT timeBucket() AS t, max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
224+
fillGaps
225+
ids={ids}
226+
timeRange={timeRange}
227+
queueName={queueName}
228+
series={[{ key: "queued", label: "Queued", color: COLORS.queued }]}
229+
/>
230+
<QueueDetailChartCard
231+
title="Scheduling delay"
232+
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`}
233+
fillGaps
234+
ids={ids}
235+
timeRange={timeRange}
236+
queueName={queueName}
237+
valueFormat={formatWaitMs}
238+
series={[
239+
{ key: "p50", label: "p50", color: COLORS.p50 },
240+
{ key: "p95", label: "p95", color: COLORS.p95 },
241+
{ key: "p99", label: "p99", color: COLORS.p99 },
242+
]}
243+
/>
244+
<QueueDetailChartCard
245+
title="Throttled buckets"
246+
query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
247+
fillGaps
248+
ids={ids}
249+
timeRange={timeRange}
250+
queueName={queueName}
251+
series={[{ key: "throttled", label: "Throttled", color: COLORS.throttled }]}
252+
/>
253+
</>
254+
);
255+
}
256+
205257
type CkBreakdown = {
206258
totalBackloggedKeys: number;
207259
keys: Array<{
@@ -212,9 +264,7 @@ type CkBreakdown = {
212264
}>;
213265
};
214266

215-
// Rendered only for queues with concurrency-key activity: live keys in the ckIndex, or
216-
// nonzero CK history in the selected range (one cached scalar query decides).
217-
function ConcurrencyKeysSection({
267+
function ConcurrencyKeysView({
218268
breakdown,
219269
loadedAt,
220270
ids,
@@ -227,20 +277,27 @@ function ConcurrencyKeysSection({
227277
timeRange: TimeRangeParams;
228278
queueName: string;
229279
}) {
230-
const { rows, showLoading } = useQueueMetric(
231-
`SELECT max(max_ck_backlogged) AS peak_keys, max(max_ck_wait_ms) AS peak_wait\nFROM queue_metrics`,
232-
{ ids, timeRange, queueName }
233-
);
234-
const row = rows[0];
235-
const hasHistory = row ? toNumber(row.peak_keys) > 0 || toNumber(row.peak_wait) > 0 : false;
236-
const hasLive = breakdown.keys.length > 0;
237-
238-
if (!hasLive && (showLoading || !hasHistory)) return null;
239-
240280
return (
241281
<>
282+
<GroupedKeyChartCard
283+
title="Backlog by key"
284+
rankExpr="max(max_queued)"
285+
seriesExpr="max(max_queued)"
286+
fillGaps
287+
ids={ids}
288+
timeRange={timeRange}
289+
queueName={queueName}
290+
/>
291+
<GroupedKeyChartCard
292+
title="Throughput by key (started)"
293+
rankExpr="deltaSumTimestampMerge(started_delta)"
294+
seriesExpr="deltaSumTimestampMerge(started_delta)"
295+
ids={ids}
296+
timeRange={timeRange}
297+
queueName={queueName}
298+
/>
242299
<QueueDetailChartCard
243-
title="Concurrency keys with backlog"
300+
title="Keys with queued runs (count)"
244301
query={`SELECT timeBucket() AS t, max(max_ck_backlogged) AS keys\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
245302
fillGaps
246303
ids={ids}
@@ -249,7 +306,7 @@ function ConcurrencyKeysSection({
249306
series={[{ key: "keys", label: "Keys", color: COLORS.ckKeys }]}
250307
/>
251308
<QueueDetailChartCard
252-
title="Most-starved key wait"
309+
title="Most-starved key wait (max across all keys)"
253310
query={`SELECT timeBucket() AS t, max(max_ck_wait_ms) AS wait\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
254311
fillGaps
255312
ids={ids}
@@ -258,7 +315,6 @@ function ConcurrencyKeysSection({
258315
valueFormat={formatWaitMs}
259316
series={[{ key: "wait", label: "Max wait", color: COLORS.ckWait }]}
260317
/>
261-
<TopKeysChartCard ids={ids} timeRange={timeRange} queueName={queueName} />
262318
<KeyStatsTable
263319
breakdown={breakdown}
264320
loadedAt={loadedAt}
@@ -286,45 +342,49 @@ const KEY_SERIES_COLORS = [
286342
"#84CC16",
287343
];
288344

289-
// Two-step top-N: rank keys by peak backlog over the range, then chart those keys as
290-
// grouped series (the per-key table is activity-bound, so ranking is a cheap scan).
291-
function TopKeysChartCard({
292-
ids,
293-
timeRange,
294-
queueName,
295-
}: {
345+
type GroupedKeyChartProps = {
346+
title: string;
347+
/** Aggregate expression ranking keys over the whole range (top 8 charted). */
348+
rankExpr: string;
349+
/** Aggregate expression charted per (bucket, key). */
350+
seriesExpr: string;
351+
fillGaps?: boolean;
352+
valueFormat?: (value: number) => string;
296353
ids: Ids;
297354
timeRange: TimeRangeParams;
298355
queueName: string;
299-
}) {
356+
};
357+
358+
// Two-step top-N: rank keys over the range, then chart those keys as grouped series
359+
// (the per-key table is activity-bound, so ranking is a cheap scan).
360+
function GroupedKeyChartCard(props: GroupedKeyChartProps) {
300361
const { rows, showLoading, failed } = useQueueMetric(
301-
`SELECT concurrency_key, max(max_queued) AS peak\nFROM queue_metrics_by_key\nGROUP BY concurrency_key\nORDER BY peak DESC\nLIMIT 8`,
302-
{ ids, timeRange, queueName }
362+
`SELECT concurrency_key, ${props.rankExpr} AS peak\nFROM queue_metrics_by_key\nGROUP BY concurrency_key\nORDER BY peak DESC\nLIMIT 8`,
363+
{ ids: props.ids, timeRange: props.timeRange, queueName: props.queueName }
303364
);
304365
const keys = useMemo(
305366
() => rows.filter((r) => toNumber(r.peak) > 0).map((r) => String(r.concurrency_key)),
306367
[rows]
307368
);
308369

309370
if (showLoading || failed || keys.length === 0) return null;
310-
return <TopKeysSeries keys={keys} ids={ids} timeRange={timeRange} queueName={queueName} />;
371+
return <GroupedKeySeries keys={keys} {...props} />;
311372
}
312373

313-
function TopKeysSeries({
374+
function GroupedKeySeries({
314375
keys,
376+
title,
377+
seriesExpr,
378+
fillGaps,
379+
valueFormat,
315380
ids,
316381
timeRange,
317382
queueName,
318-
}: {
319-
keys: string[];
320-
ids: Ids;
321-
timeRange: TimeRangeParams;
322-
queueName: string;
323-
}) {
383+
}: GroupedKeyChartProps & { keys: string[] }) {
324384
const inList = keys.map((k) => `'${trqlString(k)}'`).join(", ");
325385
const { rows, showLoading, failed } = useQueueMetric(
326-
`SELECT timeBucket() AS t, concurrency_key, max(max_queued) AS queued\nFROM queue_metrics_by_key\nWHERE concurrency_key IN (${inList})\nGROUP BY t, concurrency_key\nORDER BY t`,
327-
{ ids, timeRange, queueName, fillGaps: true }
386+
`SELECT timeBucket() AS t, concurrency_key, ${seriesExpr} AS v\nFROM queue_metrics_by_key\nWHERE concurrency_key IN (${inList})\nGROUP BY t, concurrency_key\nORDER BY t`,
387+
{ ids, timeRange, queueName, fillGaps }
328388
);
329389

330390
const data = useMemo(() => {
@@ -337,7 +397,7 @@ function TopKeysSeries({
337397
point = { bucket } as { bucket: number } & Record<string, number>;
338398
buckets.set(bucket, point);
339399
}
340-
point[String(r.concurrency_key)] = toNumber(r.queued);
400+
point[String(r.concurrency_key)] = toNumber(r.v);
341401
}
342402
return [...buckets.values()].sort((a, b) => a.bucket - b.bucket);
343403
}, [rows]);
@@ -358,7 +418,7 @@ function TopKeysSeries({
358418

359419
return (
360420
<div className="h-64">
361-
<ChartCard title="Top keys by backlog">
421+
<ChartCard title={title}>
362422
<Chart.Root
363423
config={chartConfig}
364424
data={data}
@@ -370,7 +430,9 @@ function TopKeysSeries({
370430
<Chart.Line
371431
lineType="monotone"
372432
xAxisProps={{ tickFormatter }}
433+
yAxisProps={valueFormat ? { tickFormatter: (v: number) => valueFormat(v) } : undefined}
373434
tooltipLabelFormatter={tooltipLabelFormatter}
435+
tooltipValueFormatter={valueFormat}
374436
/>
375437
</Chart.Root>
376438
</ChartCard>

0 commit comments

Comments
 (0)