diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js
index 197c98f31..94cd04d45 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js
@@ -37,6 +37,7 @@ import * as api from "./views-api.js";
import { refreshChoiceOf } from "./refresh-policy.js";
import { buildRefreshControl } from "./refresh-control.js";
import * as derive from "./derive.js";
+import { rollingHoursPicker } from "./page-range.js";
/** The FORMATTERS keys the format pickers offer (mirrors util.js FORMATTERS). */
const FORMAT_OPTIONS = ["text", "int", "num1", "num2", "rate", "pct", "ms", "mb", "time", "reltime", "bool"];
@@ -52,6 +53,8 @@ const RANGE_OPTIONS = [
{ hours: 24 * 90, label: "Last 90 days" },
];
const DEFAULT_RANGE_HOURS = 24;
+/** The longest default range a view takes: the compose runner's window ceiling (ComposeLimits.MaxWindowHours, 90 days). */
+const MAX_VIEW_RANGE_HOURS = 24 * 90;
/** The default chart per composed-panel shape (a working chart the instant a measure is picked). */
const DEFAULT_VIZ_FOR_SHAPE = { timeseries: "line", topseries: "line", ranked: "bar", scalar: "stat" };
@@ -1817,14 +1820,18 @@ export function buildViewScopeSection(model, catalog, onStructuralChange) {
const compose = catalog.compose || {};
const dimNames = variableDimensionNames(compose);
- const rangeSel = el("select", { class: "editor-select", "aria-label": "Default time range" });
- rangeSel.appendChild(el("option", { value: "", text: "Default (last 24 hours)" }));
- for (const r of RANGE_OPTIONS) rangeSel.appendChild(el("option", { value: String(r.hours), text: r.label }));
- rangeSel.value = model.rangeHours != null ? String(model.rangeHours) : "";
- rangeSel.addEventListener("change", () => {
- model.rangeHours = rangeSel.value ? parseInt(rangeSel.value, 10) : null;
- onStructuralChange();
- });
+ /* The view's default range is "this many hours back from now" (def.range.hours), so it is the shared picker's rolling-only form
+ (#5562): up to the compose runner's 90 days, a longer length greyed out with the reason, never clamped. It starts on the saved
+ hours, or the 24 hour default for a view with none, and saves what the reader picks. */
+ const rangeSel = rollingHoursPicker({
+ hours: model.rangeHours != null ? model.rangeHours : DEFAULT_RANGE_HOURS,
+ reachHours: MAX_VIEW_RANGE_HOURS,
+ label: "Default time range",
+ onChange: (hours) => {
+ model.rangeHours = hours;
+ onStructuralChange();
+ },
+ }).node;
const varsBox = el("div", { class: "item-list vars" });
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/page-range.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/page-range.js
new file mode 100644
index 000000000..ebcab2b58
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/page-range.js
@@ -0,0 +1,175 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+/*
+ * The shared time range picker on a page that shows ONE read (#5562, lane L2b): the alerts, sweeps, job history, FinOps, views and
+ * view-editor pages. It mounts timeRangePicker with the page's range and fills in what only the catalog knows once /api/catalog
+ * answers: the read's reach (`max_hours`, so a range the read refuses is greyed out with the reason, never clamped) and the main
+ * collector's actual interval (`collector_interval_minutes`, so a span holding fewer than 3 samples says how often the data is
+ * collected, and never widens the range). Until the catalog answers the picker holds the common 168 hour reach and no note.
+ *
+ * const { picker, ready } = pageRangePicker({ read: "get_job_history", spec, onChange: (spec, range) => reload() });
+ * host.appendChild(picker.node);
+ * const w = picker.window(); // { hours, asOf, live, startMs, endMs }: send `hours`, and `as_of` when it is not null
+ *
+ * `rollingOnly` is the FinOps shape (R5): "this many hours back from now", no calendar periods, no custom end, at least an hour.
+ */
+
+import { timeRangePicker } from "./time-range-picker.js";
+import { reachHours, collectorIntervalMs, resolveSpec, readWindow, browserZone, relativeSpec, ROLLING_PRESETS } from "./time-range.js";
+import { getCatalog } from "./views-api.js";
+import { apiGet } from "./util.js";
+
+const HOUR_MS = 3600000;
+const SERVER_CATALOG_TTL_MS = 5 * 60000;
+
+const serverCatalogs = new Map();
+
+/** The window a read takes for a held spec as of now: `{ hours, asOf, live, startMs, endMs }`, or null when the spec cannot be resolved
+ * (a calendar period at exactly its start, zero length). A page that keeps its choice at module scope (so a 60 s poll or a visit elsewhere keeps
+ * it) calls this on every read, so a live range slides with the clock. */
+export function windowOfSpec(spec, nowMs = Date.now(), zone = browserZone()) {
+ const r = resolveSpec(spec, nowMs, zone);
+ return r.ok ? readWindow(r.range, nowMs) : null;
+}
+
+/** Like windowOfSpec, but says why a spec cannot be read: `{ window }` or `{ error: message }`. A page shows the message (a Today range
+ * at exactly midnight) instead of quietly reading a different window (#5562 review r1 L3). */
+export function windowResultOfSpec(spec, nowMs = Date.now(), zone = browserZone()) {
+ const r = resolveSpec(spec, nowMs, zone);
+ return r.ok ? { window: readWindow(r.range, nowMs) } : { error: r.error.message };
+}
+
+/** One catalog entry by read name from the fleet-wide catalog, or null. */
+export async function catalogEntryFor(read) {
+ const catalog = await getCatalog();
+ return (catalog.reads || []).find((r) => r && r.name === read) || null;
+}
+
+/** The catalog with the SERVER's own collector intervals (a per-server schedule row wins), cached per server. Null when it cannot be read. */
+export function serverCatalog(server) {
+ const held = serverCatalogs.get(server);
+ /* An entry expires when it is looked up after its time, not by a timer: a pending timer keeps a Node harness alive for the whole
+ TTL (the picker harness waited 300 s to exit before this). */
+ if (held && held.readAt !== null && Date.now() - held.readAt >= SERVER_CATALOG_TTL_MS) serverCatalogs.delete(server);
+ if (!serverCatalogs.has(server)) {
+ /* Only a catalog that was read is kept (#5562 review r1 L2): a failed fetch is not cached as null for the page's life, and a
+ schedule edited in Settings is seen again after the entry expires. */
+ const entry = { readAt: null, fetched: null };
+ entry.fetched = apiGet("/api/catalog?server=" + encodeURIComponent(server)).then((r) => (r.kind === "data" && r.data ? r.data : null));
+ serverCatalogs.set(server, entry);
+ entry.fetched.then((catalog) => {
+ if (catalog === null) {
+ if (serverCatalogs.get(server) === entry) serverCatalogs.delete(server);
+ } else {
+ entry.readAt = Date.now();
+ }
+ }, () => {
+ if (serverCatalogs.get(server) === entry) serverCatalogs.delete(server);
+ });
+ }
+ return serverCatalogs.get(server).fetched;
+}
+
+/** The interval, in milliseconds, of one collector on this server as the catalog serves it: every read of a collector carries the same
+ * schedule, so the first windowed read that names the collector answers. Null when none does (no note). */
+export function collectorIntervalFromCatalog(catalog, collector) {
+ if (!catalog || !collector) return null;
+ for (const entry of catalog.reads || []) {
+ const param = entry && Array.isArray(entry.params) ? entry.params.find((p) => p && p.name === "hours") : null;
+ if (param && param.collector === collector) {
+ const ms = collectorIntervalMs(entry);
+ if (ms) return ms;
+ }
+ }
+ return null;
+}
+
+/**
+ * Mounts the picker for one read. `ready` resolves after the catalog entry has set the reach and the sample interval; a page that
+ * must wait for the reach (to restore a stored range) can await it.
+ *
+ * @param {object} opts
+ * @param {string} opts.read the catalog read the page calls (its `hours` param carries the reach and the collector's interval)
+ * @param {number} [opts.reachHours] a reach the page knows better than the catalog; prefer `opts.view`, which reads it from the catalog
+ * @param {string} [opts.view] a view of the read that reaches further than the read does (get_finops storage_growth): its reach is the
+ * catalog's `view_max_hours[view]`, the number the server validates
+ * @param {string} [opts.server] ask the catalog for this server's own collector interval
+ * @param {boolean} [opts.useCatalogInterval] default true; false for a read that no collector feeds
+ * @param {boolean} [opts.offerReach] add the read's reach as the longest quick choice when it is longer than the shared 30 days (Alert History's 90)
+ */
+export function pageRangePicker(opts) {
+ const picker = timeRangePicker(opts);
+ const ready = (async () => {
+ try {
+ const entry = await catalogEntryFor(opts.read);
+ const viewMax = opts.view && entry && entry.params ? Number((hoursParam(entry).view_max_hours || {})[opts.view]) : NaN;
+ picker.setReach(viewMax > 0 ? viewMax : opts.reachHours > 0 ? opts.reachHours : reachHours(entry));
+ if (opts.offerReach) offerReach(picker);
+ if (opts.useCatalogInterval === false) return;
+ if (opts.server) {
+ const interval = collectorIntervalFromCatalog(await serverCatalog(opts.server), entry && entry.params ? hoursParam(entry).collector : null);
+ picker.setSampleInterval(interval || collectorIntervalMs(entry));
+ } else {
+ picker.setSampleInterval(collectorIntervalMs(entry));
+ }
+ } catch {
+ /* A catalog that cannot be read leaves the common reach and no note, exactly the picker's defaults. */
+ }
+ })();
+ return { picker, ready };
+}
+
+/**
+ * How far back a set of reads all reach, in hours: the smallest catalog `max_hours` among them (#5562 review r1 M4, ruling R2). A page
+ * that shows several reads at one range (a server-page tab) can only offer a range every one of them takes, so the shortest reach wins.
+ * A read with no catalog row, an empty list and a catalog that could not be read all give the common 168 hours: the page never offers
+ * a range a read has not said it takes. The number comes from the catalog, the validators' own table, never from the page.
+ */
+export function readsReachHours(catalog, reads) {
+ const entries = catalog && Array.isArray(catalog.reads) ? catalog.reads : [];
+ if (!Array.isArray(reads) || reads.length === 0) return reachHours(null);
+ return Math.min(...reads.map((name) => reachHours(entries.find((r) => r && r.name === name) || null)));
+}
+
+/** The reach as the longest quick choice, when it is longer than the shared list's 30 days (#5562 review r1 M5, ruling R8). */
+export function offerReach(picker) {
+ const hours = picker.reachHours();
+ if (hours * HOUR_MS > ROLLING_PRESETS[ROLLING_PRESETS.length - 1].spanMs) picker.setExtraPresets([relativeSpec(hours * HOUR_MS)]);
+}
+
+function hoursParam(entry) {
+ return (entry.params || []).find((p) => p && p.name === "hours") || {};
+}
+
+/**
+ * A rolling-only picker for a page whose setting is "this many hours back from now" and whose reach is its own, not one read's: a
+ * Custom View's scope (the compose runner takes up to 90 days) and the view editor's default range. It offers no calendar period
+ * and no custom end, because the stored setting is a number of hours, and it greys out what is longer than `reachHours` with the
+ * reason instead of clamping it.
+ *
+ * @param {object} opts
+ * @param {number} opts.hours the length to start on, in whole hours
+ * @param {number} opts.reachHours the longest length the page takes
+ * @param {(hours: number) => void} opts.onChange raised with the whole hours picked
+ * @param {string} [opts.label] the accessible name
+ */
+export function rollingHoursPicker(opts) {
+ const picker = timeRangePicker({
+ spec: relativeSpec(opts.hours * 3600000),
+ label: opts.label || "Time range",
+ reachHours: opts.reachHours,
+ rollingOnly: true,
+ onChange: () => {
+ const w = picker.window();
+ if (w) opts.onChange(w.hours);
+ },
+ });
+ offerReach(picker);
+ return picker;
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
index 54b60054b..336f62af1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
@@ -24,9 +24,11 @@
*/
import { el, mount, readTool, apiSend, buildQuery, loadingStrip, errorStrip, emptyStrip, noticeStrip, disclosure,
- ALERT_STATE_LABELS, alertDeliveryState } from "../util.js";
+ ALERT_STATE_LABELS, alertDeliveryState, parseUtc } from "../util.js";
import { VIZ, reapplyGridSort, gridRowOf } from "../panels.js";
import { copyText } from "../grid-tools.js";
+import { pageRangePicker, windowResultOfSpec } from "../page-range.js";
+import { relativeSpec, specName } from "../time-range.js";
import { mutePrefillParams } from "../mute-context.js";
import { orderServers } from "../server-order.js";
import { getSession } from "../views-api.js";
@@ -409,31 +411,47 @@ function reconcileRows(tbody, rows, rowMap) {
}
/* The four read choices, kept at module scope so the 60 s poll (which calls renderAlerts again) and a visit to
- * another page and back keep them. The windows are the desktop Alert History's, less "All": the tool refuses
- * more than 168 hours, and a choice it would refuse is not offered. The row limits stop at the dispatch
- * layer's 1000-row ceiling. The server is the registry's server_name ("" = the whole fleet). */
-const WINDOW_CHOICES = [
- { hours: 1, label: "Last 1 Hour" },
- { hours: 4, label: "Last 4 Hours" },
- { hours: 24, label: "Last 24 Hours" },
- { hours: 168, label: "Last 7 Days" },
-];
+ * another page and back keep them. The range is the shared time range picker's spec (#5562), read as of each read so a
+ * rolling range slides. The picker takes the reach from the catalog (get_alert_history reaches 2160 hours, 90 days: the
+ * alert table's retention, DarlingRetention.AlertHistoryRetentionDays, ruling R8, #5562 L4b), so a longer range is greyed
+ * out with the reason, never clamped, and there is no All. The row limits stop at the dispatch layer's 1000-row ceiling. The server is the registry's server_name ("" = the whole fleet). */
+const DEFAULT_HOURS = 24;
const LIMIT_CHOICES = [200, 500, 1000];
-const choices = { hours: 24, limit: 200, server: "", dismissed: false };
-
-/* The get_alert_history parameters the current choices ask for. server_name and include_dismissed are left out
- * (buildQuery drops empty values) when they are at their defaults. */
-function readParams() {
+const choices = { spec: relativeSpec(DEFAULT_HOURS * 3600000), limit: 200, server: "", dismissed: false };
+
+/* The get_alert_history request the current choices ask for: `{ params, startMs }`, or `{ error }` when the range cannot be read right now
+ * (Today at exactly midnight, zero length) - the page shows that reason, never a different window (#5562 review r1 L3). server_name and
+ * include_dismissed are left out (buildQuery drops empty values) when they are at their defaults, and as_of when the range ends now.
+ * The read takes whole hours, so `startMs` is the range's exact start: the page trims the reply to it (review r1 H2). */
+function readRequest() {
+ const r = windowResultOfSpec(choices.spec);
+ if (r.error) return { error: r.error };
+ const w = r.window;
return {
- hours_back: choices.hours,
- limit: choices.limit,
- server_name: choices.server || null,
- include_dismissed: choices.dismissed ? "true" : null,
+ startMs: w.startMs,
+ params: {
+ hours_back: w.hours,
+ as_of: w.asOf,
+ limit: choices.limit,
+ server_name: choices.server || null,
+ include_dismissed: choices.dismissed ? "true" : null,
+ },
};
}
+/* Keeps the alerts at or after the range start. The newest-first reply and the row cap mean the cut cannot hide an in-range row (the
+ * rows it drops are the oldest), the same argument job-history.js makes for its runs. An unreadable time is kept.
+ * alert_time is the store's naive UTC with no zone suffix, and Date.parse would read that as the browser's LOCAL time, shifting every
+ * alert by the browser's offset, so parseUtc (util.js) reads it as UTC (#5570 review round 2, H1). */
+export function trimAlertsToStart(alerts, startMs) {
+ return alerts.filter((a) => {
+ const d = parseUtc(a && a.alert_time);
+ return d === null || d.getTime() >= startMs;
+ });
+}
+
function windowLabel() {
- return (WINDOW_CHOICES.find((w) => w.hours === choices.hours) || WINDOW_CHOICES[2]).label.toLowerCase();
+ return specName(choices.spec).toLowerCase();
}
/* The server names list_servers reported on the last successful read; the picker keeps the chosen server in the
@@ -589,8 +607,16 @@ export async function renderAlerts(main) {
"aria-label": "Filter alerts by server",
});
- const windowSel = picker("Time range", WINDOW_CHOICES.map((w) => ({ value: w.hours, label: w.label })), choices.hours);
- windowSel.setAttribute("title", "The web reads at most 7 days of alert history, so there is no All choice.");
+ const rangePicker = pageRangePicker({
+ read: "get_alert_history",
+ spec: choices.spec,
+ label: "Time range",
+ /* No collector feeds the alert log, so there is no "collected every N minutes" note. */
+ useCatalogInterval: false,
+ /* The longest choice is the alert table's retention (90 days, ruling R8), not only a typed "90d". */
+ offerReach: true,
+ onChange: (spec) => { choices.spec = spec; changed(); },
+ }).picker;
const limitSel = picker("Row limit", LIMIT_CHOICES.map((n) => ({ value: n, label: n + " rows" })), choices.limit);
const serverSel = picker("Server", serverItems(), choices.server);
const dismissedBox = el("input", { type: "checkbox", "aria-label": "Show dismissed alerts" });
@@ -603,7 +629,7 @@ export async function renderAlerts(main) {
el("h2", { text: "Alert History" }),
meta,
el("div", { class: "spacer" }),
- control("Range", windowSel),
+ el("div", { class: "range-control" }, [el("span", { text: "Range" }), rangePicker.node]),
control("Rows", limitSel),
control("Server", serverSel),
el("label", { class: "range-control" }, [dismissedBox, el("span", { text: "Show dismissed" })]),
@@ -615,7 +641,6 @@ export async function renderAlerts(main) {
/* A changed choice refetches and reconciles the table that is already drawn, the same path a poll tick takes.
It also drops the checked rows: they were picked from the list the old choice showed. */
const changed = () => { clearSelection(live); return refreshAlerts(live); };
- windowSel.addEventListener("change", () => { choices.hours = Number(windowSel.value); changed(); });
limitSel.addEventListener("change", () => { choices.limit = Number(limitSel.value); changed(); });
serverSel.addEventListener("change", () => { choices.server = serverSel.value; changed(); });
dismissedBox.addEventListener("change", () => { choices.dismissed = !!dismissedBox.checked; changed(); });
@@ -636,7 +661,8 @@ export async function renderAlerts(main) {
* in order. A reply that arrives after a newer request was made is dropped. */
async function refreshAlerts(state) {
const seq = ++state.seq;
- const res = await readTool("get_alert_history", readParams());
+ const req = readRequest();
+ const res = req.error ? { kind: "error", message: "This range cannot be read yet: " + req.error } : await readTool("get_alert_history", req.params);
if (seq !== state.seq) return;
state.meta.textContent = (choices.server ? choices.server : "fleet-wide") + " · " + windowLabel();
mount(state.noticeBox, []);
@@ -650,7 +676,7 @@ async function refreshAlerts(state) {
mount(state.tableBox, res.kind === "error" ? errorStrip(res.message) : emptyStrip(res.message));
return;
}
- state.alerts = res.data.alerts || [];
+ state.alerts = trimAlertsToStart(res.data.alerts || [], req.startMs);
state.truncated = res.data.truncated === true;
/* A checked row that is no longer listed (dismissed elsewhere, aged out) is no longer selected. */
const listed = new Set(state.alerts.filter(dismissable).map(alertKey));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js
index 0310aaa08..a412aefc7 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js
@@ -195,6 +195,8 @@ export function renderFindings(server, res) {
export const analysisFindingsTab = {
id: "recommendations",
label: "Recommendations",
+ /* The windowed reads this tab makes, so the server page reads its reach from the catalog (#5562 review r1 M4). */
+ reachReads: ["get_analysis_findings"],
build: (server, ctx) => {
const body = el("div", { class: "panel-body" }, [loadingStrip()]);
(async () => {
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/database-resources.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/database-resources.js
index d955e8d86..f69c24798 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/database-resources.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/database-resources.js
@@ -7,36 +7,16 @@
*/
/* FinOps "Database Resources" tab: per-database CPU, reads, writes, executions and file I/O from get_finops
- (view database_resources) over a window picked from 1 hour to 7 days, with each database's share of the server's CPU and I/O. */
+ (view database_resources) over a window picked on the shared time range picker (#5562: a rolling length of at least an hour, within the read's reach), with each database's share of the server's CPU and I/O. */
import { VIZ } from "../../panels.js";
import { el, mount, loadingStrip, noticeStrip, readErrorStrip, errorStrip, readTool } from "../../util.js";
import { gatedEmptyStrip } from "./gate.js";
+import { finopsWindowControl } from "./window.js";
// The default window.
const HOURS = 24;
-// The desktop's window picker (FinOpsTab.xaml ~:497-499), as hours.
-const WINDOWS = [
- { value: 1, label: "Last 1 hour" },
- { value: 4, label: "Last 4 hours" },
- { value: 12, label: "Last 12 hours" },
- { value: 24, label: "Last 24 hours" },
- { value: 168, label: "Last 7 days" },
-];
-
-// A labelled