Skip to content

Commit 3037321

Browse files
committed
feat(webapp): paginate the concurrency-keys table, removing the 50-key cap (TRI-12438)
1 parent 2bfa3c6 commit 3037321

3 files changed

Lines changed: 328 additions & 103 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The concurrency keys table on a queue's page is now paginated, so queues with thousands of keys can page through all of them instead of only showing the top 50.

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

Lines changed: 157 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type MetaFunction } from "@remix-run/react";
22
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
3-
import { useMemo, type ReactNode } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
44
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
55
import { typedjson, useTypedLoaderData } from "remix-typedjson";
66
import { z } from "zod";
@@ -49,6 +49,12 @@ import { SearchInput } from "~/components/primitives/SearchInput";
4949
import { engine } from "~/v3/runEngine.server";
5050
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
5151
import { useSearchParams } from "~/hooks/useSearchParam";
52+
import { useInterval } from "~/hooks/useInterval";
53+
import { PaginationControls } from "~/components/primitives/Pagination";
54+
import type {
55+
ConcurrencyKeyRow,
56+
ConcurrencyKeysResponse,
57+
} from "~/routes/resources.queues.concurrency-keys";
5258
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
5359
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
5460
import { requireUserId } from "~/services/session.server";
@@ -270,7 +276,7 @@ export default function Page() {
270276
</div>
271277
<div className="flex items-center gap-1.5">
272278
{view === "keys" && hasKeys ? (
273-
<SearchInput placeholder="Search keys…" paramName="query" resetParams={["key"]} />
279+
<SearchInput placeholder="Search keys…" paramName="query" resetParams={["key", "page"]} />
274280
) : null}
275281
<TimeFilter
276282
defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD}
@@ -329,13 +335,7 @@ export default function Page() {
329335
{view === "keys" && hasKeys ? (
330336
<>
331337
<MetricsLayout.Content>
332-
<KeyStatsTable
333-
breakdown={ckBreakdown}
334-
loadedAt={loadedAt}
335-
ids={ids}
336-
timeRange={timeRange}
337-
queueName={fullName}
338-
/>
338+
<KeyStatsTable ids={ids} timeRange={timeRange} queueName={fullName} />
339339
</MetricsLayout.Content>
340340
{selectedKey ? (
341341
<MetricsLayout.Content inset>
@@ -756,117 +756,171 @@ function GroupedKeySeries({
756756
);
757757
}
758758

759-
type KeyRangeStats = { started: number; peakBacklog: number; meanWaitMs: number };
759+
// One page of the paginated per-key table. The ClickHouse tier is the authority (ranked by peak
760+
// backlog over the window, with the total on every row so page + count are a single scan); each
761+
// page's keys are enriched with live "now" counts from Redis server-side. Fetched per page rather
762+
// than capped at 50, so high-cardinality queues (tens of thousands of keys) page through instead
763+
// of silently truncating. See resources.queues.concurrency-keys.
764+
function useConcurrencyKeys(opts: {
765+
ids: Ids;
766+
timeRange: TimeRangeParams;
767+
queueName: string;
768+
search: string;
769+
page: number;
770+
}) {
771+
const { ids, timeRange, queueName, search, page } = opts;
772+
const [data, setData] = useState<ConcurrencyKeysResponse | null>(null);
773+
const [isLoading, setIsLoading] = useState(true);
774+
const abortRef = useRef<AbortController | null>(null);
775+
776+
const body = useMemo(
777+
() =>
778+
JSON.stringify({
779+
organizationId: ids.organizationId,
780+
projectId: ids.projectId,
781+
environmentId: ids.environmentId,
782+
queueName,
783+
period: timeRange.period,
784+
from: timeRange.from,
785+
to: timeRange.to,
786+
search,
787+
page,
788+
}),
789+
[
790+
ids.organizationId,
791+
ids.projectId,
792+
ids.environmentId,
793+
queueName,
794+
timeRange.period,
795+
timeRange.from,
796+
timeRange.to,
797+
search,
798+
page,
799+
]
800+
);
801+
802+
const load = useCallback(() => {
803+
abortRef.current?.abort();
804+
const controller = new AbortController();
805+
abortRef.current = controller;
806+
setIsLoading(true);
807+
fetch("/resources/queues/concurrency-keys", {
808+
method: "POST",
809+
headers: { "Content-Type": "application/json" },
810+
body,
811+
signal: controller.signal,
812+
})
813+
.then((res) => res.json() as Promise<ConcurrencyKeysResponse>)
814+
.then((res) => {
815+
if (controller.signal.aborted) return;
816+
setData(res);
817+
setIsLoading(false);
818+
})
819+
.catch((error) => {
820+
if (error instanceof DOMException && error.name === "AbortError") return;
821+
if (!controller.signal.aborted) {
822+
setData({ success: false, error: error?.message ?? "Network error" });
823+
setIsLoading(false);
824+
}
825+
});
826+
}, [body]);
827+
828+
useEffect(() => {
829+
load();
830+
return () => abortRef.current?.abort();
831+
}, [load]);
760832

761-
// Live breakdown (queued/running now, oldest wait) merged with per-key range stats from
762-
// the history tier; keys with history but no live backlog still appear. Clicking a key
763-
// pins the drill-down charts via the `key` search param.
833+
// Keep the live "now" counts fresh without a manual reload.
834+
useInterval({
835+
interval: 30_000,
836+
onLoad: false,
837+
onFocus: true,
838+
pauseWhenHidden: true,
839+
callback: load,
840+
});
841+
842+
return { data, isLoading };
843+
}
844+
845+
// Paginated per-key table: which keys hold the backlog / do the work. Clicking a key pins the
846+
// drill-down charts via the `key` search param.
764847
function KeyStatsTable({
765-
breakdown,
766-
loadedAt,
767848
ids,
768849
timeRange,
769850
queueName,
770851
}: {
771-
breakdown: CkBreakdown;
772-
loadedAt: number;
773852
ids: Ids;
774853
timeRange: TimeRangeParams;
775854
queueName: string;
776855
}) {
777856
const { value, replace, del } = useSearchParams();
778857
const selectedKey = value("key");
858+
const search = value("query")?.trim() ?? "";
859+
const page = Math.max(1, Number(value("page")) || 1);
779860

780-
const { rows, showLoading } = useQueueMetric(
781-
`SELECT concurrency_key,\n deltaSumTimestampMerge(started_delta) AS started,\n max(max_queued) AS peak_backlog,\n if(sum(wait_ms_count) > 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS mean_wait\nFROM queue_metrics_by_key\nGROUP BY concurrency_key\nORDER BY peak_backlog DESC\nLIMIT 50`,
782-
{ ids, timeRange, queueName }
783-
);
861+
const { data, isLoading } = useConcurrencyKeys({ ids, timeRange, queueName, search, page });
784862

785-
const merged = useMemo(() => {
786-
const range = new Map<string, KeyRangeStats>();
787-
for (const r of rows) {
788-
range.set(String(r.concurrency_key), {
789-
started: toNumber(r.started),
790-
peakBacklog: toNumber(r.peak_backlog),
791-
meanWaitMs: toNumber(r.mean_wait),
792-
});
793-
}
794-
const liveKeys = new Set(breakdown.keys.map((k) => k.concurrencyKey));
795-
const live = breakdown.keys.map((k) => ({
796-
key: k.concurrencyKey,
797-
queued: k.queued,
798-
running: k.running,
799-
oldestWaitMs: Math.max(0, loadedAt - k.oldestEnqueuedAt),
800-
range: range.get(k.concurrencyKey),
801-
}));
802-
const historyOnly = [...range.entries()]
803-
.filter(([key]) => !liveKeys.has(key))
804-
.map(([key, stats]) => ({
805-
key,
806-
queued: 0,
807-
running: 0,
808-
oldestWaitMs: null as number | null,
809-
range: stats,
810-
}));
811-
return [...live, ...historyOnly].slice(0, 50);
812-
}, [rows, breakdown, loadedAt]);
813-
814-
// The top-bar search filters the key list by substring (case-insensitive), the same idea as the
815-
// Queues page search over queue names.
816-
const query = value("query")?.trim().toLowerCase();
817-
const filtered = useMemo(
818-
() => (query ? merged.filter((r) => r.key.toLowerCase().includes(query)) : merged),
819-
[merged, query]
820-
);
821-
822-
if (merged.length === 0) return null;
863+
const rows: ConcurrencyKeyRow[] = data?.success ? data.rows : [];
864+
const total = data?.success ? data.total : 0;
865+
const perPage = data?.success ? data.perPage : 50;
866+
const totalPages = Math.max(1, Math.ceil(total / perPage));
867+
// Only show a skeleton before the first response; keep prior rows visible while revalidating.
868+
const showLoading = isLoading && !data;
823869

824870
return (
825-
// Full-bleed, edge-to-edge like the Queues list table: a top border, no rounded side box.
826-
<Table containerClassName="border-t">
827-
<TableHeader>
828-
<TableRow>
829-
<TableHeaderCell>Key</TableHeaderCell>
830-
<TableHeaderCell alignment="right">Queued now</TableHeaderCell>
831-
<TableHeaderCell alignment="right">Running now</TableHeaderCell>
832-
<TableHeaderCell alignment="right">Oldest wait</TableHeaderCell>
833-
<TableHeaderCell alignment="right">Started</TableHeaderCell>
834-
<TableHeaderCell alignment="right">Peak backlog</TableHeaderCell>
835-
<TableHeaderCell alignment="right">Mean delay</TableHeaderCell>
836-
</TableRow>
837-
</TableHeader>
838-
<TableBody>
839-
{filtered.length === 0 ? (
840-
<TableBlankRow colSpan={7} className="text-text-dimmed">
841-
No keys match “{query}
842-
</TableBlankRow>
843-
) : null}
844-
{filtered.map((row) => (
845-
<TableRow
846-
key={row.key}
847-
isSelected={selectedKey === row.key}
848-
className="cursor-pointer"
849-
onClick={() => (selectedKey === row.key ? del("key") : replace({ key: row.key }))}
850-
>
851-
<TableCell>{row.key}</TableCell>
852-
<TableCell alignment="right">{row.queued.toLocaleString()}</TableCell>
853-
<TableCell alignment="right">{row.running.toLocaleString()}</TableCell>
854-
<TableCell alignment="right">
855-
{row.oldestWaitMs === null ? "–" : formatWaitMs(row.oldestWaitMs)}
856-
</TableCell>
857-
<TableCell alignment="right">
858-
{row.range ? row.range.started.toLocaleString() : showLoading ? "…" : "–"}
859-
</TableCell>
860-
<TableCell alignment="right">
861-
{row.range ? row.range.peakBacklog.toLocaleString() : showLoading ? "…" : "–"}
862-
</TableCell>
863-
<TableCell alignment="right">
864-
{row.range && row.range.meanWaitMs > 0 ? formatWaitMs(row.range.meanWaitMs) : "–"}
865-
</TableCell>
871+
<div className="flex flex-col">
872+
{/* Full-bleed, edge-to-edge like the Queues list table: a top border, no rounded side box. */}
873+
<Table containerClassName="border-t">
874+
<TableHeader>
875+
<TableRow>
876+
<TableHeaderCell>Key</TableHeaderCell>
877+
<TableHeaderCell alignment="right">Queued now</TableHeaderCell>
878+
<TableHeaderCell alignment="right">Running now</TableHeaderCell>
879+
<TableHeaderCell alignment="right">Oldest wait</TableHeaderCell>
880+
<TableHeaderCell alignment="right">Started</TableHeaderCell>
881+
<TableHeaderCell alignment="right">Peak backlog</TableHeaderCell>
882+
<TableHeaderCell alignment="right">Mean delay</TableHeaderCell>
866883
</TableRow>
867-
))}
868-
</TableBody>
869-
</Table>
884+
</TableHeader>
885+
<TableBody>
886+
{showLoading ? (
887+
<TableBlankRow colSpan={7} className="text-text-dimmed">
888+
Loading…
889+
</TableBlankRow>
890+
) : rows.length === 0 ? (
891+
<TableBlankRow colSpan={7} className="text-text-dimmed">
892+
{search ? `No keys match “${search}”` : "No concurrency keys"}
893+
</TableBlankRow>
894+
) : (
895+
rows.map((row) => (
896+
<TableRow
897+
key={row.key}
898+
isSelected={selectedKey === row.key}
899+
className="cursor-pointer"
900+
onClick={() => (selectedKey === row.key ? del("key") : replace({ key: row.key }))}
901+
>
902+
<TableCell>{row.key}</TableCell>
903+
<TableCell alignment="right">{row.queued.toLocaleString()}</TableCell>
904+
<TableCell alignment="right">{row.running.toLocaleString()}</TableCell>
905+
<TableCell alignment="right">
906+
{row.oldestWaitMs === null ? "–" : formatWaitMs(row.oldestWaitMs)}
907+
</TableCell>
908+
<TableCell alignment="right">{row.started.toLocaleString()}</TableCell>
909+
<TableCell alignment="right">{row.peakBacklog.toLocaleString()}</TableCell>
910+
<TableCell alignment="right">
911+
{row.meanWaitMs > 0 ? formatWaitMs(row.meanWaitMs) : "–"}
912+
</TableCell>
913+
</TableRow>
914+
))
915+
)}
916+
</TableBody>
917+
</Table>
918+
{totalPages > 1 ? (
919+
<div className="flex justify-end px-3 py-2">
920+
<PaginationControls currentPage={page} totalPages={totalPages} />
921+
</div>
922+
) : null}
923+
</div>
870924
);
871925
}
872926

0 commit comments

Comments
 (0)