From eb11f9fbae0857830f0903bf5e1aae4d409aa377 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 10:41:50 +0100 Subject: [PATCH 1/7] feat(webapp): default the queue metrics period to 1 hour and remember it The Queues list and queue detail pages opened on a 1 day window and reset to it on every navigation. They now default to 1 hour, and the last period picked is kept in a cookie the loaders read, so the first render is already on the remembered window instead of painting the default and re-fetching. Both pages resolve the period once (URL param, then absolute range, then the remembered default) and pass it down, so the picker and every chart query agree on one value. Absolute from/to ranges are not remembered. --- .../queue-metrics-remember-period.md | 6 ++ .../components/queues/QueueMetricCards.tsx | 3 +- .../components/queues/queueMetricsPeriod.ts | 70 +++++++++++++++++++ .../route.tsx | 24 +++++-- .../route.tsx | 18 ++++- .../resources.queues.concurrency-keys.ts | 4 +- 6 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 .server-changes/queue-metrics-remember-period.md create mode 100644 apps/webapp/app/components/queues/queueMetricsPeriod.ts diff --git a/.server-changes/queue-metrics-remember-period.md b/.server-changes/queue-metrics-remember-period.md new file mode 100644 index 00000000000..339d3139c8d --- /dev/null +++ b/.server-changes/queue-metrics-remember-period.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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. diff --git a/apps/webapp/app/components/queues/QueueMetricCards.tsx b/apps/webapp/app/components/queues/QueueMetricCards.tsx index 4349ea80c36..95dabff0d71 100644 --- a/apps/webapp/app/components/queues/QueueMetricCards.tsx +++ b/apps/webapp/app/components/queues/QueueMetricCards.tsx @@ -15,6 +15,7 @@ import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { QUEUE_METRICS_DEFAULT_PERIOD } from "~/components/queues/queueMetricsPeriod"; import { cn } from "~/utils/cn"; import { formatNumberCompact } from "~/utils/numberFormatter"; @@ -34,8 +35,6 @@ export const QUEUE_METRIC_COLORS = { ckWait: "#F59E0B", }; -export const QUEUE_METRICS_DEFAULT_PERIOD = "1d"; - export type QueueMetricIds = { organizationId: string; projectId: string; diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts new file mode 100644 index 00000000000..a2ab759b7ad --- /dev/null +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react"; + +/** + * The time window the queue-metrics pages (queues list + queue detail) use when the URL carries + * no explicit period, and the memory that makes the user's last pick stick. + * + * The last period picked is stored in a cookie rather than localStorage so the loaders can read it + * and the first render already uses the remembered window (with localStorage the page would paint + * the default and then re-fetch). Absolute from/to ranges are never remembered: they'd pin later + * visits to a window that goes stale. + */ +export const QUEUE_METRICS_DEFAULT_PERIOD = "1h"; + +const COOKIE_NAME = "queueMetricsPeriod"; +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + +/** The shape TimeFilter writes: a count plus a minute/hour/day unit (presets and custom durations). */ +const PERIOD_PATTERN = /^\d{1,4}[mhd]$/; + +function isPeriod(value: string | undefined | null): value is string { + return typeof value === "string" && PERIOD_PATTERN.test(value); +} + +/** Loader side: the remembered period, falling back to the default when nothing usable is stored. */ +export function queueMetricsPeriodFromRequest(request: Request): string { + const header = request.headers.get("cookie"); + if (!header) return QUEUE_METRICS_DEFAULT_PERIOD; + + for (const part of header.split(";")) { + const separator = part.indexOf("="); + if (separator === -1) continue; + if (part.slice(0, separator).trim() !== COOKIE_NAME) continue; + const value = part.slice(separator + 1).trim(); + return isPeriod(value) ? value : QUEUE_METRICS_DEFAULT_PERIOD; + } + + return QUEUE_METRICS_DEFAULT_PERIOD; +} + +/** + * Remember the period currently in the URL so the next visit to a queue-metrics page opens on it. + * Pass the raw `period` search param: an absent one (the page is on its default) or an absolute + * from/to range leaves the stored value alone. + */ +export function useRememberQueueMetricsPeriod(period: string | undefined) { + useEffect(() => { + if (!isPeriod(period)) return; + document.cookie = `${COOKIE_NAME}=${period}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; samesite=lax`; + }, [period]); +} + +/** + * The window the page should show: an explicit period wins, an absolute range means "no period", + * and everything else falls back to the remembered default the loader resolved. + */ +export function resolveQueueMetricsPeriod({ + period, + from, + to, + defaultPeriod, +}: { + period: string | undefined; + from: string | undefined; + to: string | undefined; + defaultPeriod: string; +}): string | null { + if (period) return period; + if (from || to) return null; + return defaultPeriod; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 45c395b54f3..05b702b2a02 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -102,6 +102,12 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { BigNumber } from "~/components/metrics/BigNumber"; import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server"; +import { + QUEUE_METRICS_DEFAULT_PERIOD, + queueMetricsPeriodFromRequest, + resolveQueueMetricsPeriod, + useRememberQueueMetricsPeriod, +} from "~/components/queues/queueMetricsPeriod"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -112,8 +118,6 @@ const SearchParamsSchema = z.object({ sort: z.enum(["busiest", "queued", "name"]).optional(), }); -const QUEUE_METRICS_DEFAULT_PERIOD = "1d"; - // The live "Queued" / "Running" header blocks poll ClickHouse on a short cadence so they stay // current after first paint. They read the env-wide gauges from env_metrics (the env-level rollup // of queue_metrics, cheapest for a dimension-free query), always over a fixed 15m window regardless @@ -143,6 +147,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { Object.fromEntries(url.searchParams) ); + const defaultPeriod = queueMetricsPeriodFromRequest(request); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { @@ -198,7 +204,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { period, from: parseFiniteInt(from), to: parseFiniteInt(to), - defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD, + defaultPeriod, }); const queueMetrics = queueNames.length > 0 @@ -239,6 +245,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { metrics, allocation, queueMetricsUiEnabled, + defaultPeriod, }); } catch (error) { console.error(error); @@ -362,6 +369,7 @@ function QueuesWithMetricsView() { autoReloadPollIntervalMs, metrics, allocation, + defaultPeriod, } = useTypedLoaderData(); const metricsByQueue = metrics?.byQueue ?? {}; @@ -385,10 +393,16 @@ function QueuesWithMetricsView() { // The header tiles fetch client-side with the same period/from/to the TimeFilter writes. const { value } = useSearchParams(); const timeRange = { - period: value("period") ?? null, + period: resolveQueueMetricsPeriod({ + period: value("period"), + from: value("from"), + to: value("to"), + defaultPeriod, + }), from: value("from") ?? null, to: value("to") ?? null, }; + useRememberQueueMetricsPeriod(value("period")); useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); @@ -473,7 +487,7 @@ function QueuesWithMetricsView() {
{ oldestQueuedAt: oldestQueuedAt ?? null, loadedAt: Date.now(), backPath: url.pathname.replace(/\/[^/]+$/, ""), + defaultPeriod: queueMetricsPeriodFromRequest(request), ids: { organizationId: environment.organizationId, projectId: environment.projectId, @@ -210,6 +215,7 @@ export default function Page() { loadedAt, backPath, ids, + defaultPeriod, } = useTypedLoaderData(); const plan = useCurrentPlan(); // Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for @@ -219,10 +225,16 @@ export default function Page() { const { value, replace } = useSearchParams(); const timeRange: TimeRangeParams = { - period: value("period") ?? null, + period: resolveQueueMetricsPeriod({ + period: value("period"), + from: value("from"), + to: value("to"), + defaultPeriod, + }), from: value("from") ?? null, to: value("to") ?? null, }; + useRememberQueueMetricsPeriod(value("period")); // The Concurrency keys tab exists only for queues with key activity: live keys in the // ckIndex, or nonzero CK history in the selected range (one cached scalar query decides). @@ -283,7 +295,7 @@ export default function Page() { /> ) : null} Date: Fri, 31 Jul 2026 10:57:23 +0100 Subject: [PATCH 2/7] fix(webapp): fall back to the default window for unusable queue metric periods A hand-edited `?period=garbage` or `?period=9999d` was passed straight to the metric queries, so the charts and the SSR'd table could end up on different windows. Period resolution now rejects anything the picker could not produce (including windows past the 30 day retention), and the queues list loader resolves through the same path the client queries use. --- .../components/queues/queueMetricsPeriod.ts | 18 ++++++++++++++---- .../route.tsx | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts index a2ab759b7ad..61965df9dbd 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -1,3 +1,4 @@ +import parse from "parse-duration"; import { useEffect } from "react"; /** @@ -17,8 +18,13 @@ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; /** The shape TimeFilter writes: a count plus a minute/hour/day unit (presets and custom durations). */ const PERIOD_PATTERN = /^\d{1,4}[mhd]$/; +/** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */ +const MAX_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; + function isPeriod(value: string | undefined | null): value is string { - return typeof value === "string" && PERIOD_PATTERN.test(value); + if (typeof value !== "string" || !PERIOD_PATTERN.test(value)) return false; + const ms = parse(value); + return typeof ms === "number" && ms > 0 && ms <= MAX_PERIOD_MS; } /** Loader side: the remembered period, falling back to the default when nothing usable is stored. */ @@ -50,8 +56,12 @@ export function useRememberQueueMetricsPeriod(period: string | undefined) { } /** - * The window the page should show: an explicit period wins, an absolute range means "no period", - * and everything else falls back to the remembered default the loader resolved. + * The window the page should show: a usable period in the URL wins, an absolute range means "no + * period", and everything else (including a period the picker could never produce, e.g. a + * hand-edited `?period=garbage`) falls back to the remembered default the loader resolved. + * + * Both the loaders and the client-side chart queries resolve through here, so they can't disagree + * about the window. */ export function resolveQueueMetricsPeriod({ period, @@ -64,7 +74,7 @@ export function resolveQueueMetricsPeriod({ to: string | undefined; defaultPeriod: string; }): string | null { - if (period) return period; + if (isPeriod(period)) return period; if (from || to) return null; return defaultPeriod; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 05b702b2a02..d118f01aa38 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -201,7 +201,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { q.type === "task" ? `task/${q.name}` : q.name ); const timeRange = timeFilterFromTo({ - period, + period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined, from: parseFiniteInt(from), to: parseFiniteInt(to), defaultPeriod, From 7c6cbec1f99f1323c9fda2693c65097e2b4fcfe4 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 15:10:51 +0100 Subject: [PATCH 3/7] fix(webapp): hold queue metric windows inside the plan query period The queue-metric queries that go straight to ClickHouse (the queues list table and the concurrency-keys endpoint) never applied the org's query-period limit, so a hand-typed `?period=` could read further back than the plan allows. Every query through executeQuery is already clipped this way; both of these now clip with the same limit, capped at the 30 day retention. A remembered period longer than the plan allows is clamped to the plan maximum, and the picker now renders the resolved window rather than the raw search param, so the label can no longer disagree with the data on screen. The plan cap is resolved once per load and handed to the page, replacing the copy each route derived from the client-side subscription. --- .../queues/queueMetricsPeriod.server.ts | 16 +++++++++ .../components/queues/queueMetricsPeriod.ts | 29 +++++++++++++++- .../route.tsx | 33 ++++++++++++------- .../route.tsx | 15 +++++---- .../resources.queues.concurrency-keys.ts | 21 ++++++++---- 5 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 apps/webapp/app/components/queues/queueMetricsPeriod.server.ts diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts new file mode 100644 index 00000000000..61e7cb102e3 --- /dev/null +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts @@ -0,0 +1,16 @@ +import { getLimit } from "~/services/platform.v3.server"; +import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod"; + +/** + * The furthest back this org can query queue metrics: their plan's query period, capped at the + * 30 day retention. Same limit `executeQuery` enforces, so the queue-metric queries that bypass it + * and go straight to ClickHouse stay in step with the ones that don't. + */ +export async function queueMetricsMaxPeriodDays(organizationId: string): Promise { + const planPeriodDays = await getLimit( + organizationId, + "queryPeriodDays", + QUEUE_METRICS_RETENTION_DAYS + ); + return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS); +} diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts index 61965df9dbd..44eed9daf59 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -19,7 +19,10 @@ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; const PERIOD_PATTERN = /^\d{1,4}[mhd]$/; /** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */ -const MAX_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; +export const QUEUE_METRICS_RETENTION_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_PERIOD_MS = QUEUE_METRICS_RETENTION_DAYS * DAY_MS; function isPeriod(value: string | undefined | null): value is string { if (typeof value !== "string" || !PERIOD_PATTERN.test(value)) return false; @@ -78,3 +81,27 @@ export function resolveQueueMetricsPeriod({ if (from || to) return null; return defaultPeriod; } + +/** + * Hold a period inside a day budget (the org's plan query period). A remembered period longer than + * the plan allows becomes the plan's maximum, so the picker shows the window the data covers. + */ +export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): string { + const ms = parse(period); + if (typeof ms === "number" && ms > 0 && ms <= maxPeriodDays * DAY_MS) return period; + return `${maxPeriodDays}d`; +} + +/** + * Pull a window forward to the earliest time the org's plan can query, the same clip `executeQuery` + * applies to every metric query. Queue-metric queries that go straight to ClickHouse (the queues + * list table, the concurrency-keys endpoint) have to apply it themselves, otherwise a hand-typed + * `?period=` reaches further back than the plan allows. + */ +export function clipQueueMetricsWindow( + window: { from: Date; to: Date }, + maxPeriodDays: number +): { from: Date; to: Date } { + const earliest = new Date(Date.now() - maxPeriodDays * DAY_MS); + return { from: window.from < earliest ? earliest : window.from, to: window.to }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index d118f01aa38..8b97f0959f4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -104,10 +104,13 @@ import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server"; import { QUEUE_METRICS_DEFAULT_PERIOD, + clampQueueMetricsPeriod, + clipQueueMetricsWindow, queueMetricsPeriodFromRequest, resolveQueueMetricsPeriod, useRememberQueueMetricsPeriod, } from "~/components/queues/queueMetricsPeriod"; +import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -147,8 +150,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { Object.fromEntries(url.searchParams) ); - const defaultPeriod = queueMetricsPeriodFromRequest(request); - const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { @@ -169,6 +170,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // no metrics query fires. const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug }); + const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId); + const defaultPeriod = clampQueueMetricsPeriod( + queueMetricsPeriodFromRequest(request), + maxPeriodDays + ); + try { const queueListPresenter = new QueueListPresenter(); const queues = await queueListPresenter.call({ @@ -200,12 +207,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const queueNames = queues.queues.map((q) => q.type === "task" ? `task/${q.name}` : q.name ); - const timeRange = timeFilterFromTo({ - period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined, - from: parseFiniteInt(from), - to: parseFiniteInt(to), - defaultPeriod, - }); + const timeRange = clipQueueMetricsWindow( + timeFilterFromTo({ + period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined, + from: parseFiniteInt(from), + to: parseFiniteInt(to), + defaultPeriod, + }), + maxPeriodDays + ); const queueMetrics = queueNames.length > 0 ? await presenter.getQueueListMetrics({ @@ -246,6 +256,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { allocation, queueMetricsUiEnabled, defaultPeriod, + maxPeriodDays, }); } catch (error) { console.error(error); @@ -370,6 +381,7 @@ function QueuesWithMetricsView() { metrics, allocation, defaultPeriod, + maxPeriodDays, } = useTypedLoaderData(); const metricsByQueue = metrics?.byQueue ?? {}; @@ -385,10 +397,6 @@ function QueuesWithMetricsView() { const project = useProject(); const env = useEnvironment(); const plan = useCurrentPlan(); - // Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for - // plans whose query-period limit was raised above it — a longer window would render empty. - const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; - const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30); // The header tiles fetch client-side with the same period/from/to the TimeFilter writes. const { value } = useSearchParams(); @@ -487,6 +495,7 @@ function QueuesWithMetricsView() {
{ const queue = retrieve.queue; const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name; + const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId); + const [ckBreakdown, oldestQueuedAt] = await Promise.all([ engine.concurrencyKeyBreakdown(environment, fullName, { limit: CK_LIVE_LIMIT }), // Enqueue time of the oldest run still waiting in the queue right now (any queue, keyed or @@ -138,7 +141,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { oldestQueuedAt: oldestQueuedAt ?? null, loadedAt: Date.now(), backPath: url.pathname.replace(/\/[^/]+$/, ""), - defaultPeriod: queueMetricsPeriodFromRequest(request), + defaultPeriod: clampQueueMetricsPeriod(queueMetricsPeriodFromRequest(request), maxPeriodDays), + maxPeriodDays, ids: { organizationId: environment.organizationId, projectId: environment.projectId, @@ -216,12 +220,8 @@ export default function Page() { backPath, ids, defaultPeriod, + maxPeriodDays, } = useTypedLoaderData(); - const plan = useCurrentPlan(); - // Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for - // plans whose query-period limit was raised above it — a longer window would render empty. - const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; - const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30); const { value, replace } = useSearchParams(); const timeRange: TimeRangeParams = { @@ -295,6 +295,7 @@ export default function Page() { /> ) : null} { return json({ success: false, error: "Not found" }, { status: 404 }); } - const range = timeFilterFromTo({ - period: period ?? undefined, - from: from ?? undefined, - to: to ?? undefined, - defaultPeriod: DEFAULT_PERIOD, - }); + const range = clipQueueMetricsWindow( + timeFilterFromTo({ + period: period ?? undefined, + from: from ?? undefined, + to: to ?? undefined, + defaultPeriod: DEFAULT_PERIOD, + }), + await queueMetricsMaxPeriodDays(organizationId) + ); const startTime = formatClickhouseDateTime(new Date(floorToMinute(range.from.getTime()))); const endTime = formatClickhouseDateTime(new Date(ceilToMinute(range.to.getTime()))); From 4a55f03cb0eb89ce980271c3703ee66e1d3e4a51 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 15:23:18 +0100 Subject: [PATCH 4/7] fix(webapp): keep the clipped queue metric window ordered A date range that ends before the plan's earliest queryable time had its start pulled forward past its own end, sending an inverted window to ClickHouse. It now collapses to an empty window, which is what the enforced lower bound in executeQuery produces for the same request: no rows. --- apps/webapp/app/components/queues/queueMetricsPeriod.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts index 44eed9daf59..01fc5ba9aac 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -97,11 +97,16 @@ export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): * applies to every metric query. Queue-metric queries that go straight to ClickHouse (the queues * list table, the concurrency-keys endpoint) have to apply it themselves, otherwise a hand-typed * `?period=` reaches further back than the plan allows. + * + * A range that ends before the plan's earliest queryable time collapses to an empty window rather + * than an inverted one, which is what the enforced lower bound in `executeQuery` yields for the + * same request: no rows. */ export function clipQueueMetricsWindow( window: { from: Date; to: Date }, maxPeriodDays: number ): { from: Date; to: Date } { const earliest = new Date(Date.now() - maxPeriodDays * DAY_MS); - return { from: window.from < earliest ? earliest : window.from, to: window.to }; + const from = window.from < earliest ? earliest : window.from; + return { from, to: window.to < from ? from : window.to }; } From 1ce9e995aaa5be3959c07855014395d8036e0a76 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 16:17:20 +0100 Subject: [PATCH 5/7] fix(webapp): stop rejecting long custom queue metric durations The period pattern only accepted a count of up to four digits, so a custom duration the picker allows (10000 minutes, a little under 7 days) was treated as unusable and quietly replaced with the default. The count is now unbounded and the retention bound is what rules a window out. Resolution also clamps the period from the URL, not just the remembered default, so a period wider than the plan's query period shows the window the data actually covers instead of the one that was asked for. The plan lookup reads through the limit cache and is skipped entirely on the classic Queues page, which has no time filter: the page revalidates on an interval, so an uncached platform call would repeat for the life of the tab. --- .../queues/queueMetricsPeriod.server.ts | 10 +++++++--- .../app/components/queues/queueMetricsPeriod.ts | 17 ++++++++++++----- .../route.tsx | 10 ++++++++-- .../route.tsx | 1 + 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts index 61e7cb102e3..1f0ba8b1cec 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts @@ -1,16 +1,20 @@ -import { getLimit } from "~/services/platform.v3.server"; +import { getCachedLimit } from "~/services/platform.v3.server"; import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod"; /** * The furthest back this org can query queue metrics: their plan's query period, capped at the * 30 day retention. Same limit `executeQuery` enforces, so the queue-metric queries that bypass it * and go straight to ClickHouse stay in step with the ones that don't. + * + * Read through the limit cache: the queues page revalidates on an interval, so this runs far more + * often than a one-off page load. Whole days keep the derived period string well formed. */ export async function queueMetricsMaxPeriodDays(organizationId: string): Promise { - const planPeriodDays = await getLimit( + const cached = await getCachedLimit( organizationId, "queryPeriodDays", QUEUE_METRICS_RETENTION_DAYS ); - return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS); + const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS; + return Math.max(1, Math.min(Math.floor(planPeriodDays), QUEUE_METRICS_RETENTION_DAYS)); } diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts index 01fc5ba9aac..c9bbca9ab74 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -15,8 +15,12 @@ export const QUEUE_METRICS_DEFAULT_PERIOD = "1h"; const COOKIE_NAME = "queueMetricsPeriod"; const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; -/** The shape TimeFilter writes: a count plus a minute/hour/day unit (presets and custom durations). */ -const PERIOD_PATTERN = /^\d{1,4}[mhd]$/; +/** + * The shape TimeFilter writes: a count plus a minute/hour/day unit. The count is unbounded here + * because the picker accepts any positive integer for a custom duration (`10000m` is a little under + * 7 days); the retention bound below is what rules a window out. + */ +const PERIOD_PATTERN = /^\d+[mhd]$/; /** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */ export const QUEUE_METRICS_RETENTION_DAYS = 30; @@ -61,7 +65,8 @@ export function useRememberQueueMetricsPeriod(period: string | undefined) { /** * The window the page should show: a usable period in the URL wins, an absolute range means "no * period", and everything else (including a period the picker could never produce, e.g. a - * hand-edited `?period=garbage`) falls back to the remembered default the loader resolved. + * hand-edited `?period=garbage`) falls back to the remembered default the loader resolved. The + * result is held inside the org's plan query period, since that is the window the data will cover. * * Both the loaders and the client-side chart queries resolve through here, so they can't disagree * about the window. @@ -71,15 +76,17 @@ export function resolveQueueMetricsPeriod({ from, to, defaultPeriod, + maxPeriodDays, }: { period: string | undefined; from: string | undefined; to: string | undefined; defaultPeriod: string; + maxPeriodDays: number; }): string | null { - if (isPeriod(period)) return period; + if (isPeriod(period)) return clampQueueMetricsPeriod(period, maxPeriodDays); if (from || to) return null; - return defaultPeriod; + return clampQueueMetricsPeriod(defaultPeriod, maxPeriodDays); } /** diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 8b97f0959f4..47ab9581695 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -104,6 +104,7 @@ import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server"; import { QUEUE_METRICS_DEFAULT_PERIOD, + QUEUE_METRICS_RETENTION_DAYS, clampQueueMetricsPeriod, clipQueueMetricsWindow, queueMetricsPeriodFromRequest, @@ -170,7 +171,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // no metrics query fires. const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug }); - const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId); + const maxPeriodDays = queueMetricsUiEnabled + ? await queueMetricsMaxPeriodDays(environment.organizationId) + : QUEUE_METRICS_RETENTION_DAYS; const defaultPeriod = clampQueueMetricsPeriod( queueMetricsPeriodFromRequest(request), maxPeriodDays @@ -209,7 +212,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ); const timeRange = clipQueueMetricsWindow( timeFilterFromTo({ - period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined, + period: + resolveQueueMetricsPeriod({ period, from, to, defaultPeriod, maxPeriodDays }) ?? + undefined, from: parseFiniteInt(from), to: parseFiniteInt(to), defaultPeriod, @@ -406,6 +411,7 @@ function QueuesWithMetricsView() { from: value("from"), to: value("to"), defaultPeriod, + maxPeriodDays, }), from: value("from") ?? null, to: value("to") ?? null, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 6e4e6c11a4a..37f955cd9d5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -230,6 +230,7 @@ export default function Page() { from: value("from"), to: value("to"), defaultPeriod, + maxPeriodDays, }), from: value("from") ?? null, to: value("to") ?? null, From c40f779a955e05d1fc09479cfa918e7816b2c63b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 16:29:53 +0100 Subject: [PATCH 6/7] fix(webapp): take the queue metric day budget exactly as the plan gives it Rounding the plan's query period up to a whole day would have let these pages read further back than every other metric query for a sub-day limit. The budget is now used as-is, matching the bound executeQuery enforces, and the period string derived from it steps down to hours or minutes so a fractional budget still yields a window inside it. --- .../queues/queueMetricsPeriod.server.ts | 4 ++-- .../components/queues/queueMetricsPeriod.ts | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts index 1f0ba8b1cec..2da64ba8ec1 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts @@ -7,7 +7,7 @@ import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod"; * and go straight to ClickHouse stay in step with the ones that don't. * * Read through the limit cache: the queues page revalidates on an interval, so this runs far more - * often than a one-off page load. Whole days keep the derived period string well formed. + * often than a one-off page load. */ export async function queueMetricsMaxPeriodDays(organizationId: string): Promise { const cached = await getCachedLimit( @@ -16,5 +16,5 @@ export async function queueMetricsMaxPeriodDays(organizationId: string): Promise QUEUE_METRICS_RETENTION_DAYS ); const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS; - return Math.max(1, Math.min(Math.floor(planPeriodDays), QUEUE_METRICS_RETENTION_DAYS)); + return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS); } diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts index c9bbca9ab74..d18bdbc2fce 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -25,7 +25,9 @@ const PERIOD_PATTERN = /^\d+[mhd]$/; /** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */ export const QUEUE_METRICS_RETENTION_DAYS = 30; -const DAY_MS = 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; const MAX_PERIOD_MS = QUEUE_METRICS_RETENTION_DAYS * DAY_MS; function isPeriod(value: string | undefined | null): value is string { @@ -90,13 +92,23 @@ export function resolveQueueMetricsPeriod({ } /** - * Hold a period inside a day budget (the org's plan query period). A remembered period longer than - * the plan allows becomes the plan's maximum, so the picker shows the window the data covers. + * Hold a period inside a day budget (the org's plan query period). A period longer than the plan + * allows becomes the plan's maximum, so the picker shows the window the data covers. + * + * The budget is whatever the plan says, not necessarily a whole number of days, so the replacement + * is expressed in the largest unit that divides it: rounding down keeps the period inside the + * budget rather than a hair over it. */ export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): string { + const maxMs = maxPeriodDays * DAY_MS; const ms = parse(period); - if (typeof ms === "number" && ms > 0 && ms <= maxPeriodDays * DAY_MS) return period; - return `${maxPeriodDays}d`; + if (typeof ms === "number" && ms > 0 && ms <= maxMs) return period; + + const days = Math.floor(maxMs / DAY_MS); + if (days >= 1) return `${days}d`; + const hours = Math.floor(maxMs / HOUR_MS); + if (hours >= 1) return `${hours}h`; + return `${Math.max(1, Math.floor(maxMs / MINUTE_MS))}m`; } /** From c6991874f1a41cded082b9ddb80fa2b64bdeec23 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 31 Jul 2026 18:39:41 +0100 Subject: [PATCH 7/7] fix(webapp): keep a plan-limit failure from taking the Queues page down The query-period lookup runs before the block that renders the Queues page without metrics when ClickHouse is unavailable, so a failure reading the limit would have cost the whole page rather than the time filter. It now falls back to the retention cap, which is the widest window the data can cover anyway. --- .../queues/queueMetricsPeriod.server.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts index 2da64ba8ec1..fa50e464a4f 100644 --- a/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts @@ -1,4 +1,5 @@ import { getCachedLimit } from "~/services/platform.v3.server"; +import { logger } from "~/services/logger.server"; import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod"; /** @@ -7,14 +8,24 @@ import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod"; * and go straight to ClickHouse stay in step with the ones that don't. * * Read through the limit cache: the queues page revalidates on an interval, so this runs far more - * often than a one-off page load. + * often than a one-off page load. Never throws, so a cache or platform outage costs the caller its + * time filter rather than the whole page: the retention cap is the widest window the data can cover + * anyway, and the queries stay tenant-scoped either way. */ export async function queueMetricsMaxPeriodDays(organizationId: string): Promise { - const cached = await getCachedLimit( - organizationId, - "queryPeriodDays", - QUEUE_METRICS_RETENTION_DAYS - ); - const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS; - return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS); + try { + const cached = await getCachedLimit( + organizationId, + "queryPeriodDays", + QUEUE_METRICS_RETENTION_DAYS + ); + const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS; + return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS); + } catch (error) { + logger.warn("Queue metrics query period limit unavailable, falling back to retention", { + organizationId, + error, + }); + return QUEUE_METRICS_RETENTION_DAYS; + } }