Skip to content

Commit 5aa2259

Browse files
committed
feat(webapp,clickhouse): standard time filter for queue metrics pages
The queues list and queue detail pages now use the shared TimeFilter (any preset period or a custom date range) and everything on the page follows it: header tiles, per queue metric columns, charts, and stats. The custom period buttons, hand rolled chart cards, and duplicated metric fetch loops are replaced by the ChartCard and Chart primitives, UsageSparkline, and a shared useMetricResourceQuery hook. The ClickHouse list queries take an explicit end bound so fixed ranges query only their window.
1 parent 31fb262 commit 5aa2259

5 files changed

Lines changed: 323 additions & 468 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { useInterval } from "./useInterval";
3+
4+
export type MetricResourceRow = Record<string, number | string | null>;
5+
6+
type MetricResourceResponse =
7+
| { success: true; data: { rows: MetricResourceRow[] } }
8+
| { success: false; error: string };
9+
10+
export type MetricResourceTimeRange = {
11+
period: string | null;
12+
from: string | null;
13+
to: string | null;
14+
};
15+
16+
export type MetricResourceQueryOptions = {
17+
organizationId: string;
18+
projectId: string;
19+
environmentId: string;
20+
timeRange: MetricResourceTimeRange;
21+
defaultPeriod: string;
22+
queues?: string[];
23+
fillGaps?: boolean;
24+
refreshIntervalMs?: number;
25+
};
26+
27+
/**
28+
* Client-fetch a TRQL query from the metric resource route (like the dashboard
29+
* widgets): own loading state, interval + on-focus refresh, abort on change/unmount.
30+
*/
31+
export function useMetricResourceQuery(query: string, opts: MetricResourceQueryOptions) {
32+
const [rows, setRows] = useState<MetricResourceRow[] | null>(null);
33+
const [isLoading, setIsLoading] = useState(true);
34+
const [failed, setFailed] = useState(false);
35+
const abortRef = useRef<AbortController | null>(null);
36+
37+
const {
38+
organizationId,
39+
projectId,
40+
environmentId,
41+
defaultPeriod,
42+
fillGaps,
43+
refreshIntervalMs = 60_000,
44+
} = opts;
45+
const { period, from, to } = opts.timeRange;
46+
const queuesKey = opts.queues && opts.queues.length > 0 ? opts.queues.join(",") : undefined;
47+
48+
const load = useCallback(() => {
49+
abortRef.current?.abort();
50+
const controller = new AbortController();
51+
abortRef.current = controller;
52+
setIsLoading(true);
53+
fetch("/resources/metric", {
54+
method: "POST",
55+
headers: { "Content-Type": "application/json" },
56+
body: JSON.stringify({
57+
query,
58+
scope: "environment",
59+
period: period ?? (from || to ? null : defaultPeriod),
60+
from,
61+
to,
62+
fillGaps: !!fillGaps,
63+
organizationId,
64+
projectId,
65+
environmentId,
66+
...(queuesKey !== undefined ? { queues: queuesKey.split(",") } : {}),
67+
}),
68+
signal: controller.signal,
69+
})
70+
.then((res) => res.json() as Promise<MetricResourceResponse>)
71+
.then((data) => {
72+
if (controller.signal.aborted) return;
73+
if (data.success) {
74+
setRows(data.data.rows);
75+
setFailed(false);
76+
} else {
77+
setFailed(true);
78+
}
79+
setIsLoading(false);
80+
})
81+
.catch((error) => {
82+
if (error instanceof DOMException && error.name === "AbortError") return;
83+
if (!controller.signal.aborted) {
84+
setFailed(true);
85+
setIsLoading(false);
86+
}
87+
});
88+
}, [
89+
query,
90+
period,
91+
from,
92+
to,
93+
defaultPeriod,
94+
fillGaps,
95+
organizationId,
96+
projectId,
97+
environmentId,
98+
queuesKey,
99+
]);
100+
101+
useEffect(() => {
102+
load();
103+
return () => abortRef.current?.abort();
104+
}, [load]);
105+
106+
useInterval({ interval: refreshIntervalMs, onLoad: false, onFocus: true, callback: load });
107+
108+
return { rows: rows ?? [], isLoading, showLoading: isLoading && !rows, failed };
109+
}

apps/webapp/app/presenters/v3/QueueMetricsPresenter.server.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,6 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
22
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
33
import { logger } from "~/services/logger.server";
44

5-
export const QUEUE_METRICS_WINDOWS = {
6-
"1h": 3600,
7-
"6h": 21600,
8-
"24h": 86400,
9-
} as const;
10-
11-
export type QueueMetricsWindow = keyof typeof QUEUE_METRICS_WINDOWS;
12-
13-
export function isQueueMetricsWindow(value: unknown): value is QueueMetricsWindow {
14-
return typeof value === "string" && value in QUEUE_METRICS_WINDOWS;
15-
}
16-
175
export type QueueListMetric = {
186
p50WaitMs: number | null;
197
p95WaitMs: number | null;
@@ -23,7 +11,6 @@ export type QueueListMetric = {
2311
};
2412

2513
export type QueueListMetrics = {
26-
window: QueueMetricsWindow;
2714
bucketStartMs: number;
2815
bucketIntervalMs: number;
2916
byQueue: Map<string, QueueListMetric>;
@@ -41,30 +28,30 @@ function finiteOrNull(value: number): number | null {
4128

4229
export class QueueMetricsPresenter {
4330
/**
44-
* Recent per-queue metrics for a fixed set of queues (the visible list page),
31+
* Per-queue metrics over a time range for a fixed set of queues (the visible list page),
4532
* scoped to one ClickHouse query window so cost is independent of total queue count.
4633
* Degrades to an empty map if ClickHouse is unavailable so the live list still renders.
4734
*/
4835
public async getQueueListMetrics({
4936
environment,
5037
queueNames,
51-
window,
38+
from,
39+
to,
5240
}: {
5341
environment: AuthenticatedEnvironment;
5442
queueNames: string[];
55-
window: QueueMetricsWindow;
43+
from: Date;
44+
to: Date;
5645
}): Promise<QueueListMetrics> {
57-
const windowSeconds = QUEUE_METRICS_WINDOWS[window];
58-
const bucketSeconds = Math.max(60, Math.round(windowSeconds / SPARKLINE_POINTS));
59-
const numBuckets = Math.ceil(windowSeconds / bucketSeconds);
60-
const nowSeconds = Math.floor(Date.now() / 1000);
46+
const rangeSeconds = Math.max(60, Math.round((to.getTime() - from.getTime()) / 1000));
47+
const bucketSeconds = Math.max(60, Math.round(rangeSeconds / SPARKLINE_POINTS));
48+
const numBuckets = Math.max(1, Math.ceil(rangeSeconds / bucketSeconds));
6149
const gridStartSeconds =
62-
Math.floor((nowSeconds - windowSeconds) / bucketSeconds) * bucketSeconds;
50+
Math.floor(Math.floor(from.getTime() / 1000) / bucketSeconds) * bucketSeconds;
6351
const bucketStartMs = gridStartSeconds * 1000;
6452
const bucketIntervalMs = bucketSeconds * 1000;
6553

6654
const empty: QueueListMetrics = {
67-
window,
6855
bucketStartMs,
6956
bucketIntervalMs,
7057
byQueue: new Map(),
@@ -86,6 +73,7 @@ export class QueueMetricsPresenter {
8673
environmentId: environment.id,
8774
queueNames,
8875
startTime: formatClickhouseDateTime(new Date(bucketStartMs)),
76+
endTime: formatClickhouseDateTime(to),
8977
};
9078

9179
const [summaryResult, sparklineResult] = await Promise.all([
@@ -137,7 +125,7 @@ export class QueueMetricsPresenter {
137125
});
138126
}
139127

140-
return { window, bucketStartMs, bucketIntervalMs, byQueue };
128+
return { bucketStartMs, bucketIntervalMs, byQueue };
141129
} catch (error) {
142130
logger.warn("QueueMetricsPresenter: failed to load queue metrics", {
143131
error: error instanceof Error ? error.message : String(error),

0 commit comments

Comments
 (0)