|
1 | 1 | import { type MetaFunction } from "@remix-run/react"; |
2 | 2 | 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"; |
4 | 4 | import type { QueueItem } from "@trigger.dev/core/v3/schemas"; |
5 | 5 | import { typedjson, useTypedLoaderData } from "remix-typedjson"; |
6 | 6 | import { z } from "zod"; |
@@ -49,6 +49,12 @@ import { SearchInput } from "~/components/primitives/SearchInput"; |
49 | 49 | import { engine } from "~/v3/runEngine.server"; |
50 | 50 | import { TimeFilter } from "~/components/runs/v3/SharedFilters"; |
51 | 51 | 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"; |
52 | 58 | import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; |
53 | 59 | import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; |
54 | 60 | import { requireUserId } from "~/services/session.server"; |
@@ -270,7 +276,7 @@ export default function Page() { |
270 | 276 | </div> |
271 | 277 | <div className="flex items-center gap-1.5"> |
272 | 278 | {view === "keys" && hasKeys ? ( |
273 | | - <SearchInput placeholder="Search keys…" paramName="query" resetParams={["key"]} /> |
| 279 | + <SearchInput placeholder="Search keys…" paramName="query" resetParams={["key", "page"]} /> |
274 | 280 | ) : null} |
275 | 281 | <TimeFilter |
276 | 282 | defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD} |
@@ -329,13 +335,7 @@ export default function Page() { |
329 | 335 | {view === "keys" && hasKeys ? ( |
330 | 336 | <> |
331 | 337 | <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} /> |
339 | 339 | </MetricsLayout.Content> |
340 | 340 | {selectedKey ? ( |
341 | 341 | <MetricsLayout.Content inset> |
@@ -756,117 +756,171 @@ function GroupedKeySeries({ |
756 | 756 | ); |
757 | 757 | } |
758 | 758 |
|
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]); |
760 | 832 |
|
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. |
764 | 847 | function KeyStatsTable({ |
765 | | - breakdown, |
766 | | - loadedAt, |
767 | 848 | ids, |
768 | 849 | timeRange, |
769 | 850 | queueName, |
770 | 851 | }: { |
771 | | - breakdown: CkBreakdown; |
772 | | - loadedAt: number; |
773 | 852 | ids: Ids; |
774 | 853 | timeRange: TimeRangeParams; |
775 | 854 | queueName: string; |
776 | 855 | }) { |
777 | 856 | const { value, replace, del } = useSearchParams(); |
778 | 857 | const selectedKey = value("key"); |
| 858 | + const search = value("query")?.trim() ?? ""; |
| 859 | + const page = Math.max(1, Number(value("page")) || 1); |
779 | 860 |
|
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 }); |
784 | 862 |
|
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; |
823 | 869 |
|
824 | 870 | 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> |
866 | 883 | </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> |
870 | 924 | ); |
871 | 925 | } |
872 | 926 |
|
|
0 commit comments