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.server.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts new file mode 100644 index 00000000000..fa50e464a4f --- /dev/null +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.server.ts @@ -0,0 +1,31 @@ +import { getCachedLimit } from "~/services/platform.v3.server"; +import { logger } from "~/services/logger.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. 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 { + 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; + } +} diff --git a/apps/webapp/app/components/queues/queueMetricsPeriod.ts b/apps/webapp/app/components/queues/queueMetricsPeriod.ts new file mode 100644 index 00000000000..d18bdbc2fce --- /dev/null +++ b/apps/webapp/app/components/queues/queueMetricsPeriod.ts @@ -0,0 +1,131 @@ +import parse from "parse-duration"; +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. 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; + +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 { + 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. */ +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: 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. 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. + */ +export function resolveQueueMetricsPeriod({ + period, + from, + to, + defaultPeriod, + maxPeriodDays, +}: { + period: string | undefined; + from: string | undefined; + to: string | undefined; + defaultPeriod: string; + maxPeriodDays: number; +}): string | null { + if (isPeriod(period)) return clampQueueMetricsPeriod(period, maxPeriodDays); + if (from || to) return null; + return clampQueueMetricsPeriod(defaultPeriod, maxPeriodDays); +} + +/** + * 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 <= 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`; +} + +/** + * 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. + * + * 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); + const from = window.from < earliest ? earliest : window.from; + return { from, to: window.to < from ? from : 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 45c395b54f3..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 @@ -102,6 +102,16 @@ 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, + QUEUE_METRICS_RETENTION_DAYS, + clampQueueMetricsPeriod, + clipQueueMetricsWindow, + queueMetricsPeriodFromRequest, + resolveQueueMetricsPeriod, + useRememberQueueMetricsPeriod, +} from "~/components/queues/queueMetricsPeriod"; +import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -112,8 +122,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 @@ -163,6 +171,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // no metrics query fires. const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug }); + const maxPeriodDays = queueMetricsUiEnabled + ? await queueMetricsMaxPeriodDays(environment.organizationId) + : QUEUE_METRICS_RETENTION_DAYS; + const defaultPeriod = clampQueueMetricsPeriod( + queueMetricsPeriodFromRequest(request), + maxPeriodDays + ); + try { const queueListPresenter = new QueueListPresenter(); const queues = await queueListPresenter.call({ @@ -194,12 +210,17 @@ 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, - from: parseFiniteInt(from), - to: parseFiniteInt(to), - defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD, - }); + const timeRange = clipQueueMetricsWindow( + timeFilterFromTo({ + period: + resolveQueueMetricsPeriod({ period, from, to, defaultPeriod, maxPeriodDays }) ?? + undefined, + from: parseFiniteInt(from), + to: parseFiniteInt(to), + defaultPeriod, + }), + maxPeriodDays + ); const queueMetrics = queueNames.length > 0 ? await presenter.getQueueListMetrics({ @@ -239,6 +260,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { metrics, allocation, queueMetricsUiEnabled, + defaultPeriod, + maxPeriodDays, }); } catch (error) { console.error(error); @@ -362,6 +385,8 @@ function QueuesWithMetricsView() { autoReloadPollIntervalMs, metrics, allocation, + defaultPeriod, + maxPeriodDays, } = useTypedLoaderData(); const metricsByQueue = metrics?.byQueue ?? {}; @@ -377,18 +402,21 @@ 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(); const timeRange = { - period: value("period") ?? null, + period: resolveQueueMetricsPeriod({ + period: value("period"), + from: value("from"), + to: value("to"), + defaultPeriod, + maxPeriodDays, + }), from: value("from") ?? null, to: value("to") ?? null, }; + useRememberQueueMetricsPeriod(value("period")); useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); @@ -473,7 +501,8 @@ 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 @@ -134,6 +141,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { oldestQueuedAt: oldestQueuedAt ?? null, loadedAt: Date.now(), backPath: url.pathname.replace(/\/[^/]+$/, ""), + defaultPeriod: clampQueueMetricsPeriod(queueMetricsPeriodFromRequest(request), maxPeriodDays), + maxPeriodDays, ids: { organizationId: environment.organizationId, projectId: environment.projectId, @@ -210,19 +219,23 @@ export default function Page() { loadedAt, 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 = { - period: value("period") ?? null, + period: resolveQueueMetricsPeriod({ + period: value("period"), + from: value("from"), + to: value("to"), + defaultPeriod, + maxPeriodDays, + }), 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 +296,8 @@ 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())));