/** * Request-log filters shared by the Requests page, its pagination links and the CSV export route. * Pure helpers: no database or framework imports so they can be used anywhere. */ export type SearchParams = Record; export const REQUEST_RANGES = ["24h", "7d", "30d", "custom"] as const; export type RequestRange = (typeof REQUEST_RANGES)[number]; export const REQUEST_STATUSES = ["success", "failed", "pending"] as const; export type RequestStatus = (typeof REQUEST_STATUSES)[number]; export const REQUEST_SOURCES = ["api", "playground", "sdk"] as const; export type RequestSource = (typeof REQUEST_SOURCES)[number]; export const REQUEST_NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const; export const REQUESTS_PAGE_SIZE = 50; export const REQUESTS_EXPORT_MAX = 10_000; export interface RequestFilters { range: RequestRange; /** ISO date (YYYY-MM-DD), only used when range = custom. */ from?: string; to?: string; status?: RequestStatus; domain?: string; network?: (typeof REQUEST_NETWORKS)[number]; country?: string; httpStatus?: number; requestId?: string; source?: RequestSource; page: number; } function first(v: string | string[] | undefined): string | undefined { const s = Array.isArray(v) ? v[0] : v; const t = s?.trim(); return t ? t : undefined; } function oneOf(v: string | undefined, allowed: T): T[number] | undefined { return v && (allowed as readonly string[]).includes(v) ? (v as T[number]) : undefined; } const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; export function parseRequestFilters(sp: SearchParams): RequestFilters { const from = first(sp.from); const to = first(sp.to); const hasCustomDates = Boolean((from && ISO_DATE.test(from)) || (to && ISO_DATE.test(to))); const rangeRaw = oneOf(first(sp.range), REQUEST_RANGES); const range: RequestRange = hasCustomDates && (!rangeRaw || rangeRaw === "custom") ? "custom" : rangeRaw && rangeRaw !== "custom" ? rangeRaw : hasCustomDates ? "custom" : "7d"; const httpRaw = first(sp.http); const httpStatus = httpRaw && /^\d{3}$/.test(httpRaw) ? Number(httpRaw) : undefined; const pageRaw = Number(first(sp.page) ?? "1"); const country = first(sp.country)?.toUpperCase(); return { range, from: range === "custom" && from && ISO_DATE.test(from) ? from : undefined, to: range === "custom" && to && ISO_DATE.test(to) ? to : undefined, status: oneOf(first(sp.status), REQUEST_STATUSES), domain: first(sp.domain)?.toLowerCase().slice(0, 253), network: oneOf(first(sp.network), REQUEST_NETWORKS), country: country && /^[A-Z]{2}$/.test(country) ? country : undefined, httpStatus, requestId: first(sp.id)?.slice(0, 64), source: oneOf(first(sp.source), REQUEST_SOURCES), page: Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1, }; } /** Absolute time window for a filter set. `to` is exclusive; null means "now". */ export function filterWindow(f: RequestFilters, now = new Date()): { from: Date; to: Date | null } { if (f.range === "custom") { const from = f.from ? new Date(`${f.from}T00:00:00.000Z`) : new Date(now.getTime() - 30 * 86_400_000); const to = f.to ? new Date(new Date(`${f.to}T00:00:00.000Z`).getTime() + 86_400_000) : null; return { from, to }; } const hours = f.range === "24h" ? 24 : f.range === "7d" ? 24 * 7 : 24 * 30; return { from: new Date(now.getTime() - hours * 3_600_000), to: null }; } /** Serialize filters back to a query string. `overrides` lets callers change a page or drop a key (pass undefined). */ export function filtersToSearchParams(f: RequestFilters, overrides: Partial> = {}): URLSearchParams { const merged: Record = { range: f.range, from: f.from, to: f.to, status: f.status, domain: f.domain, network: f.network, country: f.country, http: f.httpStatus, id: f.requestId, source: f.source, page: f.page > 1 ? f.page : undefined, }; for (const [k, v] of Object.entries(overrides)) { const key = k === "httpStatus" ? "http" : k === "requestId" ? "id" : k; merged[key] = v; } if (merged.range === "7d" && !merged.from && !merged.to) delete merged.range; if (merged.range !== "custom") { delete merged.from; delete merged.to; } const sp = new URLSearchParams(); for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== "" && v !== null) sp.set(k, String(v)); return sp; } export function requestsHref(f: RequestFilters, overrides?: Partial>): string { const qs = filtersToSearchParams(f, overrides).toString(); return `/dashboard/requests${qs ? `?${qs}` : ""}`; } export interface FilterChip { key: keyof RequestFilters; label: string; value: string; } export function activeFilterChips(f: RequestFilters): FilterChip[] { const chips: FilterChip[] = []; if (f.range === "custom") chips.push({ key: "range", label: "Dates", value: `${f.from ?? "…"} → ${f.to ?? "now"}` }); else if (f.range !== "7d") chips.push({ key: "range", label: "Range", value: f.range === "24h" ? "Last 24 hours" : "Last 30 days" }); if (f.status) chips.push({ key: "status", label: "Status", value: f.status }); if (f.domain) chips.push({ key: "domain", label: "Domain", value: f.domain }); if (f.network) chips.push({ key: "network", label: "Network", value: f.network }); if (f.country) chips.push({ key: "country", label: "Country", value: f.country }); if (f.httpStatus) chips.push({ key: "httpStatus", label: "HTTP", value: String(f.httpStatus) }); if (f.requestId) chips.push({ key: "requestId", label: "Request", value: f.requestId }); if (f.source) chips.push({ key: "source", label: "Source", value: f.source }); return chips; } /** Remove one chip: custom ranges drop both dates and fall back to the default window. */ export function hrefWithoutChip(f: RequestFilters, key: keyof RequestFilters): string { if (key === "range") return requestsHref({ ...f, range: "7d", from: undefined, to: undefined, page: 1 }); return requestsHref({ ...f, [key]: undefined, page: 1 }); }