Skip to content

Commit 5f29ae4

Browse files
authored
feat(webapp): default the queue metrics period to 1 hour and remember it (#4438)
## Summary The Queues list and queue detail pages opened on a 1 day window, and went back to it every time you navigated between queues or reloaded. They now default to the last hour, and the period you pick is remembered across navigations and refreshes. ## Design The last period is stored in a `queueMetricsPeriod` cookie, written client-side whenever a `period` lands in the URL and read by both loaders. A cookie rather than localStorage because the queues list renders its per-queue metrics columns server-side: with localStorage the page would paint the 1 hour default and then re-fetch, and the picker would flash the wrong window. Both pages resolve the window once, in one place, and pass it down: ```ts period: resolveQueueMetricsPeriod({ period: value("period"), // a usable period in the URL wins from: value("from"), // an absolute range means "no period" to: value("to"), defaultPeriod, // otherwise the remembered default from the loader }), ``` That keeps the picker pill and every chart query on the same value, so no call site falls back to its own default. Periods the picker could never produce (a hand-edited `?period=garbage`, or a window past the 30 day retention) fall back to the default, and the picker renders the resolved window rather than the raw search param so the label can't disagree with the data. Absolute from/to ranges, including drag-to-zoom, are not remembered, since they would pin later visits to a window that has gone stale. While wiring that up: the two queue-metric queries that go straight to ClickHouse (the list table and the concurrency-keys endpoint) never applied the org's `queryPeriodDays` limit, so a hand-typed `?period=` read further back than the plan allows. Everything behind `/resources/metric` is already clipped that way by `executeQuery`; both of these now clip with the same limit, capped at the retention window, and the plan cap is resolved once per load and handed to the page instead of each route deriving its own copy from the client-side subscription. Verified on both pages: default with no cookie is 1 hr, picking 6 hrs survives navigating away and back to a param-free URL and a hard reload, clearing the cookie returns to 1 hr, an oversized period falls back without being remembered, and an absolute range still renders as a range.
1 parent 8f66af6 commit 5f29ae4

7 files changed

Lines changed: 250 additions & 33 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 Queues pages now open on the last hour instead of the last day, and remember the time period you picked when you navigate between queues or reload the page.

apps/webapp/app/components/queues/QueueMetricCards.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Header3 } from "~/components/primitives/Headers";
1515
import { Paragraph } from "~/components/primitives/Paragraph";
1616
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
1717
import { useSearchParams } from "~/hooks/useSearchParam";
18+
import { QUEUE_METRICS_DEFAULT_PERIOD } from "~/components/queues/queueMetricsPeriod";
1819
import { cn } from "~/utils/cn";
1920
import { formatNumberCompact } from "~/utils/numberFormatter";
2021

@@ -34,8 +35,6 @@ export const QUEUE_METRIC_COLORS = {
3435
ckWait: "#F59E0B",
3536
};
3637

37-
export const QUEUE_METRICS_DEFAULT_PERIOD = "1d";
38-
3938
export type QueueMetricIds = {
4039
organizationId: string;
4140
projectId: string;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getCachedLimit } from "~/services/platform.v3.server";
2+
import { logger } from "~/services/logger.server";
3+
import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod";
4+
5+
/**
6+
* The furthest back this org can query queue metrics: their plan's query period, capped at the
7+
* 30 day retention. Same limit `executeQuery` enforces, so the queue-metric queries that bypass it
8+
* and go straight to ClickHouse stay in step with the ones that don't.
9+
*
10+
* Read through the limit cache: the queues page revalidates on an interval, so this runs far more
11+
* often than a one-off page load. Never throws, so a cache or platform outage costs the caller its
12+
* time filter rather than the whole page: the retention cap is the widest window the data can cover
13+
* anyway, and the queries stay tenant-scoped either way.
14+
*/
15+
export async function queueMetricsMaxPeriodDays(organizationId: string): Promise<number> {
16+
try {
17+
const cached = await getCachedLimit(
18+
organizationId,
19+
"queryPeriodDays",
20+
QUEUE_METRICS_RETENTION_DAYS
21+
);
22+
const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS;
23+
return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS);
24+
} catch (error) {
25+
logger.warn("Queue metrics query period limit unavailable, falling back to retention", {
26+
organizationId,
27+
error,
28+
});
29+
return QUEUE_METRICS_RETENTION_DAYS;
30+
}
31+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import parse from "parse-duration";
2+
import { useEffect } from "react";
3+
4+
/**
5+
* The time window the queue-metrics pages (queues list + queue detail) use when the URL carries
6+
* no explicit period, and the memory that makes the user's last pick stick.
7+
*
8+
* The last period picked is stored in a cookie rather than localStorage so the loaders can read it
9+
* and the first render already uses the remembered window (with localStorage the page would paint
10+
* the default and then re-fetch). Absolute from/to ranges are never remembered: they'd pin later
11+
* visits to a window that goes stale.
12+
*/
13+
export const QUEUE_METRICS_DEFAULT_PERIOD = "1h";
14+
15+
const COOKIE_NAME = "queueMetricsPeriod";
16+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
17+
18+
/**
19+
* The shape TimeFilter writes: a count plus a minute/hour/day unit. The count is unbounded here
20+
* because the picker accepts any positive integer for a custom duration (`10000m` is a little under
21+
* 7 days); the retention bound below is what rules a window out.
22+
*/
23+
const PERIOD_PATTERN = /^\d+[mhd]$/;
24+
25+
/** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */
26+
export const QUEUE_METRICS_RETENTION_DAYS = 30;
27+
28+
const MINUTE_MS = 60 * 1000;
29+
const HOUR_MS = 60 * MINUTE_MS;
30+
const DAY_MS = 24 * HOUR_MS;
31+
const MAX_PERIOD_MS = QUEUE_METRICS_RETENTION_DAYS * DAY_MS;
32+
33+
function isPeriod(value: string | undefined | null): value is string {
34+
if (typeof value !== "string" || !PERIOD_PATTERN.test(value)) return false;
35+
const ms = parse(value);
36+
return typeof ms === "number" && ms > 0 && ms <= MAX_PERIOD_MS;
37+
}
38+
39+
/** Loader side: the remembered period, falling back to the default when nothing usable is stored. */
40+
export function queueMetricsPeriodFromRequest(request: Request): string {
41+
const header = request.headers.get("cookie");
42+
if (!header) return QUEUE_METRICS_DEFAULT_PERIOD;
43+
44+
for (const part of header.split(";")) {
45+
const separator = part.indexOf("=");
46+
if (separator === -1) continue;
47+
if (part.slice(0, separator).trim() !== COOKIE_NAME) continue;
48+
const value = part.slice(separator + 1).trim();
49+
return isPeriod(value) ? value : QUEUE_METRICS_DEFAULT_PERIOD;
50+
}
51+
52+
return QUEUE_METRICS_DEFAULT_PERIOD;
53+
}
54+
55+
/**
56+
* Remember the period currently in the URL so the next visit to a queue-metrics page opens on it.
57+
* Pass the raw `period` search param: an absent one (the page is on its default) or an absolute
58+
* from/to range leaves the stored value alone.
59+
*/
60+
export function useRememberQueueMetricsPeriod(period: string | undefined) {
61+
useEffect(() => {
62+
if (!isPeriod(period)) return;
63+
document.cookie = `${COOKIE_NAME}=${period}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; samesite=lax`;
64+
}, [period]);
65+
}
66+
67+
/**
68+
* The window the page should show: a usable period in the URL wins, an absolute range means "no
69+
* period", and everything else (including a period the picker could never produce, e.g. a
70+
* hand-edited `?period=garbage`) falls back to the remembered default the loader resolved. The
71+
* result is held inside the org's plan query period, since that is the window the data will cover.
72+
*
73+
* Both the loaders and the client-side chart queries resolve through here, so they can't disagree
74+
* about the window.
75+
*/
76+
export function resolveQueueMetricsPeriod({
77+
period,
78+
from,
79+
to,
80+
defaultPeriod,
81+
maxPeriodDays,
82+
}: {
83+
period: string | undefined;
84+
from: string | undefined;
85+
to: string | undefined;
86+
defaultPeriod: string;
87+
maxPeriodDays: number;
88+
}): string | null {
89+
if (isPeriod(period)) return clampQueueMetricsPeriod(period, maxPeriodDays);
90+
if (from || to) return null;
91+
return clampQueueMetricsPeriod(defaultPeriod, maxPeriodDays);
92+
}
93+
94+
/**
95+
* Hold a period inside a day budget (the org's plan query period). A period longer than the plan
96+
* allows becomes the plan's maximum, so the picker shows the window the data covers.
97+
*
98+
* The budget is whatever the plan says, not necessarily a whole number of days, so the replacement
99+
* is expressed in the largest unit that divides it: rounding down keeps the period inside the
100+
* budget rather than a hair over it.
101+
*/
102+
export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): string {
103+
const maxMs = maxPeriodDays * DAY_MS;
104+
const ms = parse(period);
105+
if (typeof ms === "number" && ms > 0 && ms <= maxMs) return period;
106+
107+
const days = Math.floor(maxMs / DAY_MS);
108+
if (days >= 1) return `${days}d`;
109+
const hours = Math.floor(maxMs / HOUR_MS);
110+
if (hours >= 1) return `${hours}h`;
111+
return `${Math.max(1, Math.floor(maxMs / MINUTE_MS))}m`;
112+
}
113+
114+
/**
115+
* Pull a window forward to the earliest time the org's plan can query, the same clip `executeQuery`
116+
* applies to every metric query. Queue-metric queries that go straight to ClickHouse (the queues
117+
* list table, the concurrency-keys endpoint) have to apply it themselves, otherwise a hand-typed
118+
* `?period=` reaches further back than the plan allows.
119+
*
120+
* A range that ends before the plan's earliest queryable time collapses to an empty window rather
121+
* than an inverted one, which is what the enforced lower bound in `executeQuery` yields for the
122+
* same request: no rows.
123+
*/
124+
export function clipQueueMetricsWindow(
125+
window: { from: Date; to: Date },
126+
maxPeriodDays: number
127+
): { from: Date; to: Date } {
128+
const earliest = new Date(Date.now() - maxPeriodDays * DAY_MS);
129+
const from = window.from < earliest ? earliest : window.from;
130+
return { from, to: window.to < from ? from : window.to };
131+
}

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
102102
import { BigNumber } from "~/components/metrics/BigNumber";
103103
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
104104
import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server";
105+
import {
106+
QUEUE_METRICS_DEFAULT_PERIOD,
107+
QUEUE_METRICS_RETENTION_DAYS,
108+
clampQueueMetricsPeriod,
109+
clipQueueMetricsWindow,
110+
queueMetricsPeriodFromRequest,
111+
resolveQueueMetricsPeriod,
112+
useRememberQueueMetricsPeriod,
113+
} from "~/components/queues/queueMetricsPeriod";
114+
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
105115

106116
const SearchParamsSchema = z.object({
107117
query: z.string().optional(),
@@ -112,8 +122,6 @@ const SearchParamsSchema = z.object({
112122
sort: z.enum(["busiest", "queued", "name"]).optional(),
113123
});
114124

115-
const QUEUE_METRICS_DEFAULT_PERIOD = "1d";
116-
117125
// The live "Queued" / "Running" header blocks poll ClickHouse on a short cadence so they stay
118126
// current after first paint. They read the env-wide gauges from env_metrics (the env-level rollup
119127
// of queue_metrics, cheapest for a dimension-free query), always over a fixed 15m window regardless
@@ -163,6 +171,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
163171
// no metrics query fires.
164172
const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug });
165173

174+
const maxPeriodDays = queueMetricsUiEnabled
175+
? await queueMetricsMaxPeriodDays(environment.organizationId)
176+
: QUEUE_METRICS_RETENTION_DAYS;
177+
const defaultPeriod = clampQueueMetricsPeriod(
178+
queueMetricsPeriodFromRequest(request),
179+
maxPeriodDays
180+
);
181+
166182
try {
167183
const queueListPresenter = new QueueListPresenter();
168184
const queues = await queueListPresenter.call({
@@ -194,12 +210,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
194210
const queueNames = queues.queues.map((q) =>
195211
q.type === "task" ? `task/${q.name}` : q.name
196212
);
197-
const timeRange = timeFilterFromTo({
198-
period,
199-
from: parseFiniteInt(from),
200-
to: parseFiniteInt(to),
201-
defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD,
202-
});
213+
const timeRange = clipQueueMetricsWindow(
214+
timeFilterFromTo({
215+
period:
216+
resolveQueueMetricsPeriod({ period, from, to, defaultPeriod, maxPeriodDays }) ??
217+
undefined,
218+
from: parseFiniteInt(from),
219+
to: parseFiniteInt(to),
220+
defaultPeriod,
221+
}),
222+
maxPeriodDays
223+
);
203224
const queueMetrics =
204225
queueNames.length > 0
205226
? await presenter.getQueueListMetrics({
@@ -239,6 +260,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
239260
metrics,
240261
allocation,
241262
queueMetricsUiEnabled,
263+
defaultPeriod,
264+
maxPeriodDays,
242265
});
243266
} catch (error) {
244267
console.error(error);
@@ -362,6 +385,8 @@ function QueuesWithMetricsView() {
362385
autoReloadPollIntervalMs,
363386
metrics,
364387
allocation,
388+
defaultPeriod,
389+
maxPeriodDays,
365390
} = useTypedLoaderData<typeof loader>();
366391

367392
const metricsByQueue = metrics?.byQueue ?? {};
@@ -377,18 +402,21 @@ function QueuesWithMetricsView() {
377402
const project = useProject();
378403
const env = useEnvironment();
379404
const plan = useCurrentPlan();
380-
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
381-
// plans whose query-period limit was raised above it — a longer window would render empty.
382-
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
383-
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);
384405

385406
// The header tiles fetch client-side with the same period/from/to the TimeFilter writes.
386407
const { value } = useSearchParams();
387408
const timeRange = {
388-
period: value("period") ?? null,
409+
period: resolveQueueMetricsPeriod({
410+
period: value("period"),
411+
from: value("from"),
412+
to: value("to"),
413+
defaultPeriod,
414+
maxPeriodDays,
415+
}),
389416
from: value("from") ?? null,
390417
to: value("to") ?? null,
391418
};
419+
useRememberQueueMetricsPeriod(value("period"));
392420

393421
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
394422

@@ -473,7 +501,8 @@ function QueuesWithMetricsView() {
473501
</div>
474502
<div className="flex items-center gap-1.5">
475503
<TimeFilter
476-
defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD}
504+
period={timeRange.period ?? undefined}
505+
defaultPeriod={defaultPeriod}
477506
labelName="Period"
478507
maxPeriodDays={maxPeriodDays}
479508
shortcut={{ key: "d" }}

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncConte
2222
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
2323
import {
2424
QUEUE_METRIC_COLORS as COLORS,
25-
QUEUE_METRICS_DEFAULT_PERIOD,
2625
QueueMetricChartCard as QueueDetailChartCard,
2726
type QueueMetricIds as Ids,
2827
type QueueMetricTimeRange as TimeRangeParams,
@@ -55,7 +54,6 @@ import type {
5554
ConcurrencyKeyRow,
5655
ConcurrencyKeysResponse,
5756
} from "~/routes/resources.queues.concurrency-keys";
58-
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
5957
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
6058
import { requireUserId } from "~/services/session.server";
6159
import { docsPath, EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder";
@@ -67,6 +65,13 @@ import {
6765
QueueOverrideConcurrencyButton,
6866
QueuePauseResumeButton,
6967
} from "~/components/queues/QueueControls";
68+
import {
69+
clampQueueMetricsPeriod,
70+
queueMetricsPeriodFromRequest,
71+
resolveQueueMetricsPeriod,
72+
useRememberQueueMetricsPeriod,
73+
} from "~/components/queues/queueMetricsPeriod";
74+
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
7075
import { LinkButton } from "~/components/primitives/Buttons";
7176
import { RunsIcon } from "~/assets/icons/RunsIcon";
7277
import { InfoPanel } from "~/components/primitives/InfoPanel";
@@ -106,6 +111,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
106111
const queue = retrieve.queue;
107112
const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name;
108113

114+
const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId);
115+
109116
const [ckBreakdown, oldestQueuedAt] = await Promise.all([
110117
engine.concurrencyKeyBreakdown(environment, fullName, { limit: CK_LIVE_LIMIT }),
111118
// Enqueue time of the oldest run still waiting in the queue right now (any queue, keyed or
@@ -134,6 +141,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
134141
oldestQueuedAt: oldestQueuedAt ?? null,
135142
loadedAt: Date.now(),
136143
backPath: url.pathname.replace(/\/[^/]+$/, ""),
144+
defaultPeriod: clampQueueMetricsPeriod(queueMetricsPeriodFromRequest(request), maxPeriodDays),
145+
maxPeriodDays,
137146
ids: {
138147
organizationId: environment.organizationId,
139148
projectId: environment.projectId,
@@ -210,19 +219,23 @@ export default function Page() {
210219
loadedAt,
211220
backPath,
212221
ids,
222+
defaultPeriod,
223+
maxPeriodDays,
213224
} = useTypedLoaderData<typeof loader>();
214-
const plan = useCurrentPlan();
215-
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
216-
// plans whose query-period limit was raised above it — a longer window would render empty.
217-
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
218-
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);
219225

220226
const { value, replace } = useSearchParams();
221227
const timeRange: TimeRangeParams = {
222-
period: value("period") ?? null,
228+
period: resolveQueueMetricsPeriod({
229+
period: value("period"),
230+
from: value("from"),
231+
to: value("to"),
232+
defaultPeriod,
233+
maxPeriodDays,
234+
}),
223235
from: value("from") ?? null,
224236
to: value("to") ?? null,
225237
};
238+
useRememberQueueMetricsPeriod(value("period"));
226239

227240
// The Concurrency keys tab exists only for queues with key activity: live keys in the
228241
// ckIndex, or nonzero CK history in the selected range (one cached scalar query decides).
@@ -283,7 +296,8 @@ export default function Page() {
283296
/>
284297
) : null}
285298
<TimeFilter
286-
defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD}
299+
period={timeRange.period ?? undefined}
300+
defaultPeriod={defaultPeriod}
287301
labelName="Period"
288302
maxPeriodDays={maxPeriodDays}
289303
shortcut={{ key: "d" }}

0 commit comments

Comments
 (0)