|
| 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 | +} |
0 commit comments