Skip to content

Time range picker: more presets, calendar periods and typed ranges - #5570

Merged
erikdarlingdata merged 38 commits into
devfrom
fix/5562-time-range-picker
Oct 9, 2026
Merged

erikdarlingdata merged 38 commits into
devfrom
fix/5562-time-range-picker

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

One time range picker for Lite, the Darling Viewer and the Darling web: more presets, calendar periods and typed ranges. The design is in the last comment on the issue. This PR is built in parts by several lanes on one branch; each part adds its own section below.

Shared model and grammar (lane L1)

The pure model lives in PerformanceMonitor.Ui/TimeRange/ and has no WPF in it. Lite and the Viewer both reference it. The web module (time-range.js) has to produce the same results.

Shared test cases: Darling/Darling.Tests/Fixtures/time-range-cases.json (100 cases). Zone ids are IANA names. The file's _doc block says how to read it: instants are ISO UTC strings, echo is the exact label text, and error is a stable code (the message text is not compared). The C# tests in Darling/Darling.Tests/TimeRangeTests.cs run every case. They live in Darling.Tests only, because a Darling file that Lite.Tests compiles has to be named in the Lite path filter in build.yml. The expected values were produced by an independent Python implementation (zoneinfo), not by the C# code.

Grammar (case-insensitive; commas, extra spaces and en or em dashes do not matter)

  • Relative: 45m, 12 hours, 10d, 2 weeks, last 3 days, past 90 min, 1.5 hours. Units m/min/minute(s), h/hr/hour(s), d/day(s), w/wk/week(s), mo/month(s). A month is 30 real days. Real elapsed time, so a day is 24 hours across a daylight saving change. There is no year unit.
  • Calendar: today, yesterday, this week / week to date / wtd, last week / previous week, this month / mtd, last month, this year / ytd, last year. Wall-clock days in the display zone, weeks start Monday. Today and the to-date periods end at now; the previous periods end at the next local midnight (23 and 25 hour days happen). A boundary is the earliest instant labelled at or after the wall midnight, so neighbouring periods tile with no gap.
  • Fixed: one point, or two joined by -, to, until (or a single unspaced dash). A point is a date, a time, or both.
    • Dates: Oct 1, October 1st 2025, 1 Oct, 10/1, 10/1/26, 2026-10-01, today, yesterday (month first for slashes).
    • Times: 1:00 am, 12pm, 13:00, 13:00:30, noon, midnight.
    • One date alone is the whole day. As the end of a range, a date alone includes that whole day (Oct 1 - Oct 2 ends at Oct 3 00:00).
    • A time alone takes the other end's date, or the latest day that is not in the future. An end earlier on the clock than the start is the next day (22:00 - 02:00).
    • A date without a year is the latest such date not in the future. An end without a year before its start rolls into the next year only when that end has already happened (Dec 30 - Jan 2 works in January; Oct 2 - Oct 1 is refused).
    • Typed wall times go through DisplayZone.ToUtcBound: the start takes the earliest instant, the end the latest, a repeated hour widens, a skipped time maps to the change instant.
  • Growing: since 10/1, Oct 2 12pm to now, 10/1 - now. The end slides with now. since 9am is the latest 9am not in the future.
  • Unix: 1790852543 - 1791457343 or since 1790852543. Ten digits are seconds, thirteen are milliseconds.
  • Refused with a plain reason, never widened: anything under 5 minutes (too_short), an end before the start, nonsense, impossible dates and times, a start in the future for since. Codes: empty, unrecognized, bad_date, bad_time, bad_range, needs_end, too_short, end_before_start, start_in_future, too_far_back (a relative count over 100 years). There is no upper cap in the model.

Echo / label format

<length> <start> - <end> (<zone>), for example 3d Oct 5, 12:00 am - Oct 8, 7:01 am (UTC-04:00). Length is Nd (floored) for a day or more, else Hh Mm with zero parts dropped. A bound is Mon D, h:mm am|pm (English month abbreviations, lower-case am/pm), , YYYY after the day when either end is not in now's year, :ss for fixed ranges when the seconds are not zero, and +hh:mm after a wall time that happens twice. The zone is UTC-04:00, or UTC-05:00 to UTC-04:00 when a change falls between the ends.

C# API (all in PerformanceMonitor.Ui)

  • TimeRangeParser.Parse(text, nowUtc, zone) returns { Range, Echo, Error, ErrorCode, Spec, Ok }. Now is passed in, never read.
  • TimeRangeSpec (Relative / Calendar / Fixed / Since): TryResolve(nowUtc, zone, out ResolvedTimeRange, out TimeRangeError), a stable Id (5m, 4h, 1w, 1mo, today, previous-week, fixed:..., since:...) with TryFromId for settings, WholeHours for readers that still take hours back.
  • ResolvedTimeRange: StartUtc, EndUtc (naive UTC), IsLive, Span, Length, StartText, EndText, ZoneText, Label.
  • TimeRangePresets: Rolling (5m 15m 30m 1h 4h 1d 2d 1w 1mo), CalendarPeriods, FromLegacyHours(int) (1/4/12/24/168 and any other whole hours; 24 is 1d, 168 is 1w), CurrentLength(spec, now, zone), Find(id).
  • TimeRangeNotes.DataStartNote(range, dataStartUtc) ("Data starts Oct 3, 2:00 pm" when the range starts more than 90 minutes before the data) and SampleIntervalNote(span, interval) ("Data here is collected every 5 minutes." when the span holds fewer than 3 samples).

WPF control (PerformanceMonitor.Ui/TimeRangePicker.xaml and .cs)

A button names the range ("Past 4 hours", "Previous Week", "Custom (2d)"). The resolved start, end and zone sit beside it. The popup holds the nine short presets, the eight calendar periods (each with its current length, greyed while under 5 minutes), a text box that previews the parsed range and notes before Apply (or the reason it failed), and a Monday-first calendar for picking days. Enter applies, Esc closes.

Properties for the tab call sites:

  • ZoneProvider (Func<TimeZoneInfo>, default the machine zone) and NowProvider (for tests).
  • Value (a TimeRangeSpec; setting it redraws without raising the event) and Resolve() (the window to read now, so a live range keeps sliding). Call Refresh() when the zone changes or on a refresh tick.
  • Select(spec) holds a range and raises the event, as if the user picked it.
  • DataStartUtc: shows "Data starts Oct 3, 2:00 pm" when the range starts more than 90 minutes before it.
  • SampleInterval: a tab sets its main collector's actual interval. A range with fewer than 3 samples shows "Data here is collected every N minutes."
  • Compact: hides the detail beside the button and moves it into the tooltip (for FinOps).
  • Event RangeChanged carries Spec (hold or persist Spec.Id) and Range.

It is themed through BackgroundBrush, ForegroundBrush, ForegroundDimBrush, BorderBrush, AccentBrush, AccentForegroundBrush, ErrorBrush and WarningTextBrush, which both apps define in all three themes. The calendar chrome is recoloured from those brushes when the popup opens. This replaces Lite's ApplyThemeToCalendar and the Viewer's ApplyDarkThemeToCalendar, which L3 can delete. PerformanceCalendar is a month heatmap and did not fit, so the stock WPF Calendar is used.

Not done here, by design: no call site, web or MCP file is touched. One test edit outside the new files: FleetIdentifierScrubTests now allows utc as an ordinal role word, because its short-name-and-ordinal pattern read the label text "UTC-04:00" as a server name.

Test plan

  • TimeRangeFixtureTests (100 shared cases plus a zone-id check), TimeRangeModelTests and TimeRangePickerControlTests (STA: construction, events, zone, notes, compact, typed preview, popup open with calendar theming and a picked range): 144 passed together with FleetIdentifierScrubTests.
  • Every Stage=Guard class in Darling.Tests (1409 passed) and in Lite.Tests (441 passed, after dropping the Lite link that tripped DarlingReadsTraitGuardTests), and the Census and Ratchet classes (393 passed).
  • Source-scan classes that read PerformanceMonitor.Ui: ClipboardWriteCensusTests, SerialLoopStoreSizeSourceTests, StatementScrubViewerPlanWithheldTests, TsqlConventionGuardTests, Viewer4577Tests: 86 passed.
  • Builds with zero new warnings: PerformanceMonitor.Ui, Lite, Lite.Tests, the Viewer, Darling.Tests.
  • Rendered the popup in dark and light brushes to check layout.
  • The full suite was not run on this machine; CI decides.

Web and MCP reach (lane L4)

The server side of the picker: how far back each web or MCP read may be asked to go, and what the web needs to grey out a period a tab cannot read.

What changes

  • McpHelpers.ValidateWindow(hoursBack, asOf, maxHours, out end) and ValidateHoursBack(hoursBack, maxHours) take a ceiling the read declares. A window past it is refused with the same error shape as today, never shortened. MaxHoursBack stays 168, and the old two-argument calls now delegate to the new ones, so every read that does not opt in answers byte for byte as before.
  • WebReadReach (Darling service) is the one table of every windowed web read: its shape (bucketed trend, list or ranking, latest snapshot), its reach in hours, and its main collector. A read's validator takes its ceiling from this table.
  • Only bucketed trends that already read the hourly rollup opt in, at 2160 hours (90 days): the query, procedure and Query Store duration trends. Their points are whole hours, so the payload is bounded. Lists and rankings stay at 168 hours, even the ones that route through a rollup, because the MCP ceiling rises for bucketed trends only. The raw-table trends (waits, CPU, memory, file I/O, tempdb, perfmon, blocking, the PostgreSQL trends) stay at 168 hours until a timing run on a large store says a month is affordable; raising one later is a one-line change to its table row plus its validator call.
  • /api/catalog now puts max_hours, shape, collector, collector_interval_minutes and collector_default_interval_minutes on the hours param of each windowed read. The interval is the schedule in force: the fleet-wide row over the shipped default, or, with ?server=NAME, that server's own row first. The web keeps sending whole hours and as_of; there is no minute-grain parameter.
  • The three opted-in tools say in their hours_back description that they go up to 2160.

Decisions to check

  • Rankings such as Top Queries by CPU route through the rollups too, but stay at 168 hours on the web. The brief's two rules collide there (rollup query tabs reach 90 days; only bucketed trends may raise the MCP ceiling). I followed the stricter one. Giving them 90 days is a one-line change per read in the table and the validator, plus the text and plan clamp (ClampToTextHorizon) the brief describes; none of the three opted-in trends carries query text or plans, so no clamp note was needed here.
  • get_query_trend (one query's series by hash) is not bucketed, so it stays at 168 hours.
  • Lite's own MCP tools keep 168 hours; Lite only shares the helper.
  • Collector ids come from CollectorScheduleDefaults. Reads no collector feeds (analysis, alerts, the monitoring tool's own reads) and get_server_trend (whose collector depends on the metric) have no collector id.

Test plan

  • WebReadReachTests (15 passed with the live class): every windowed web read has a row, and no row names a missing read (the census that fails when a read is added without one); every collector id exists; only bucketed trends reach past 168 and exactly the three opted-in reads do; each opted-in validator reads its ceiling from the table and no other does; the catalog serves max_hours and the collector interval, including per-server over fleet over default; the overload accepts up to its ceiling, refuses beyond it, and the 168 path is unchanged.
  • WebReadReachLiveTests against a TimescaleDB scratch store: with one busy hour 60 days back and its raw rows purged, the query and procedure duration trends asked for 2160 hours through the web dispatch answer from the hourly rollup; 2161 is refused; the Query Store trend takes the same ceiling; Top Queries, the tempdb trend, the heatmap and CPU utilization still refuse 169.
  • Every Stage=Guard class in Darling.Tests (1409 passed), the Census (475) and Ratchet (15) classes, and the source-scan classes that read DarlingWebEndpoints, McpHelpers or DarlingMcpTrendTools, all green.
  • Lite.Tests builds clean; its MCP classes (717 passed) and Census classes (58 passed) are green, since McpHelpers is shared.
  • Builds: Darling service, Darling.Tests, Lite.Tests, zero warnings.
  • The full suite was not run on this machine; CI decides.

Not done

  • Timings on a large store for the raw-table trends, so none of them is raised yet.
  • Seven series statements were not captured for the timing statements because their discovery probe finds no series in an empty store: six get_server_trend metrics (memory clerks, latch, spinlock, collector duration, tempdb file I/O, file I/O throughput) and get_pg_query_duration_trend.

Fixes #5562

Viewer (lane L3b and L3b-fix)

Every time range control in the Darling Viewer uses the shared picker. The desktop has no reach limit: a long period reads what the data holds, through the routers that already serve long windows.

What changed:

  • Server tab toolbar: the preset combo and the From/To pickers are one picker. The window is a TimeRangeSpec held by the picker and drawn in the tab's own display zone (Server, Local or UTC).
  • GetWindowUtc(), GetWindowHoursBack() and TimeRangeIndexToHours keep their shapes, so the readers below them do not change. The new ViewerTimeRangeWindow class holds the arithmetic (window, whole hours, legacy index, Apply to All, collector cadence), so tests can pin it without a window.
  • Sub-hour spans flow through, because nearly every Viewer read takes two instants. The Overview lanes always get the exact start and end. GetWindowHoursBack() rounds up to one hour for any integer reader left.
  • Calendar periods and typed ranges flow through GetWindowUtc() in the display zone. A finished range is a past range, and the CPU chart bounds its end.
  • Apply to All sends the held range. A calendar period goes as the instants it names in the source tab's zone, so every tab windows on the same period.
  • Sample-interval note: the picker's SampleInterval comes from the main collector of the tab on screen. It uses the schedule overrides (read at most every five minutes), else the shipped cadence. A tab with no single main collector shows no note.
  • Alert History: the read and Dismiss All take an exclusive end, so Dismiss All touches exactly the rows the grid shows for a finished range. There are two new methods, GetAlertHistoryWindowAsync and DismissAllVisibleAlertsWindowAsync. The old methods delegate.
  • Preferences: DefaultTimeRangeIndex (0 to 4) stays as the legacy field. The new DefaultTimeRange string holds a rolling length or a calendar period. Normalize maps between them and writes the nearest legacy index back for older builds. Custom stays unpersisted.
  • Removed ApplyDarkThemeToCalendar and the Viewer's own From/To code.
  • Alert History and Job History lose the old "Last Year" entry. Job History's 90 days is typed (90d) and 30 days is 1mo.

What L3b-fix added (the four gaps L3b left):

  • FinOps (R5): the four lists and the object heatmap are Compact and rolling-only. The picker has a new RollingUnit property (PerformanceMonitor.Ui/TimeRangePicker.xaml.cs) and a pure rule, RollingUnitRule (PerformanceMonitor.Ui/TimeRange/RollingUnitRule.cs). With a unit set, the picker hides the calendar periods and the calendar. It greys presets under one unit, and it refuses calendar, fixed and "since" ranges and any length that is not a whole number of units. A typed range shows the reason ("This read takes whole hours; try 2h."). The lists use one hour (FinOpsTab.xaml.cs), the heatmap one day. A picker no longer offers a range its read cannot honor.
  • Job History (R6): GetJobHistoryAsync takes untilUtc (ViewerDataService.JobHistory.cs) and hands it to the shared reader's existing JobHistoryFilter.UntilUtc. The SQL applies the bound before the per-server top-N and the row cap. The reader's end is inclusive and the picker's end is exclusive, so the Viewer takes one microsecond off it. JobHistoryTab.xaml.cs passes the end for a finished range, and the client trim is gone. A live test on a seeded store (10 row cap, 40 runs after the end) fails on the old shape and passes now.
  • Data-start note (R7): every banner site reaches ViewerServerTab.UpdateTruncationBanner. The 29 event-surface sites reach it through ShowEventDataStartAsync. That method hands its floor to ViewerDataStartNote.Feed. The feed sets the note of the tab the banner sits in, and only while the banner is on screen. The same floor feeds Job History's own picker. No new query. The six sites that used a feeding helper went back to the plain probe await, so their older source pins hold again. A source-scan test pins 35 UpdateTruncationBanner sites and 29 ShowEventDataStartAsync sites, so a new site must be counted.
  • Alert History (R8): the picker gets one extra choice, "All" (SetLongestChoice), set to ViewerTimeRangeWindow.AlertHistoryLongestChoice. That is 90 days, from DarlingRetentionHorizons.AlertHistoryRetentionDays (DailySummaryHorizon.cs:66), capped at 365 days if the retention is raised.
  • Old pins that L3b left stale are updated: the Job History tab pin, the data-start banner pins, and a stacked <summary> in ViewerServerTab.TimeRange.cs.

Viewer test plan

  • New ViewerTimeRangeGapFixTests pins the hour and day rules and the rolling-only picker on an STA thread. It also pins the five FinOps pickers, the banner site counts and the feed, the Alert History choice and the Job History tab. A live end-bound test runs on a seeded store. Passed.
  • ViewerTimeRangeWindowTests, ViewerTypedRangeZoneTests, ViewerApplyToAllClockTests, ViewerAlertClockCacheTests, TimeRangePickerControlTests: passed.
  • The Viewer, FinOps, Alert, Job History, Settings, Banner and TimeRange classes ran with the Census, Ratchet, DocCommentHygiene, Startup, Storage, RawWindowFloor and capability classes. Result: 7,686 passed, 583 skipped, 0 failed (no store). The Stage=Guard trait: 1,409 run, two failures (a live-class collection attribute and a member-scan list) fixed, then re-run green.
  • The Job History live tests (per-server top-N, grouped stats, data start, MCP tool) and the new end-bound test passed on a throwaway PostgreSQL. The new test fails when the end bound is switched off.
  • Darling.Tests and Lite.Tests build with zero warnings.
  • Not run: the whole suite, a visual check of the toolbar, and a rebuild after the last rebase onto the other lanes' pushes.

Not changed, for the Lite lane: Lite has the same FinOps, Job History and Alert History readers. The Lite lane can reuse RollingUnit and SetLongestChoice on the shared control.

Web picker (lane L2a)

The web grammar and picker are in wwwroot/js/time-range.js and wwwroot/js/time-range-picker.js, with css/time-range.css. The server page uses them in place of its preset list and its two date-time boxes.

Module API for the other web pages

time-range.js is pure: no DOM, no clock, no imports. Every function is given the now (epoch milliseconds) and the zone, which is an IANA name. browserZone() gives the browser's. A spec is a plain object: {kind:"relative", spanMs}, {kind:"calendar", period}, {kind:"fixed", startMs, endMs} or {kind:"since", startMs}.

  • parseRange(text, nowMs, zone) returns {ok, range, spec, echo, error, errorCode}. The error codes are the fixture's.
  • resolveSpec(spec, nowMs, zone) returns {ok, range} or {ok:false, error:{code,message}}. A range has startMs, endMs, live, spanMs, length, startText, endText, zoneText and label.
  • ROLLING_PRESETS, CALENDAR_PRESETS, specId, specFromId, specName, fromLegacyHours, wholeHours, currentLength, relativeSpec, fixedSpec.
  • readWindow(range) gives {hours, asOf, live, startMs, endMs}. That is the whole hours (at least 1, rounded up) and the as_of end the reads take. asOf is null for a live range.
  • Reach and notes: reachHours(catalogEntry) reads max_hours from the entry or its hours param and falls back to 168. collectorIntervalMs(catalogEntry) reads collector_interval_minutes the same way. The other helpers are reachRefusal(spanMs, hours), sampleIntervalNote(spanMs, intervalMs) and dataStartNote(range, dataStartMs).

time-range-picker.js exports timeRangePicker(opts). A page mounts it like this:

const picker = timeRangePicker({ spec, reachHours: 168, onChange: (spec, range) => redraw() });
host.appendChild(picker.node);
const w = picker.window(); // {hours, asOf, live, startMs, endMs}, or null if the range cannot be resolved now

Options: spec, zone, now, reachHours, sampleIntervalMs, dataStartMs, compact, label, onChange. Methods:

  • window(), resolve(), spec() and setSpec(specOrId), which holds without raising onChange.
  • select(spec), which acts as if the reader picked it and returns the refusal or null.
  • refresh() (a live range slides), setReach, setSampleInterval, setDataStart, openPopup and close.

To persist a choice, store specId(spec) and read it back with specFromId.

The popup lists the nine short presets and eight calendar periods, each with its current length. It also has a text box that shows the parsed range or the reason before Apply, and two date-time boxes that write the typed form into the text box. Enter applies, and Esc closes and returns focus to the button. A range longer than the reach, or a period under 5 minutes, is greyed out with the reason. A typed range gets the same checks. The button and the boxes carry accessible names. The document listener exists only while the popup is open.

What the server page does

  • The whole-hour presets within the reach (1h, 4h, 1d, 2d, 1w) are the page's own range. The reads take them as plain hours, as before. RANGE_OPTIONS is derived from ROLLING_PRESETS, not listed by hand.
  • Everything else is a per-server custom range: 5m, 15m, 30m, the calendar periods, and anything typed or picked. It is fetched as whole hours back from its end (as_of for a fixed range, none for a live one). The existing setActiveRange path trims it to the exact pair. The label says where totals begin when the span is not whole hours ("totals and rankings aggregate from ...").
  • resolveCustomRange, applyCustomRange and rangeContext keep their shapes, so the tabs and the chart menu are unchanged. The 1 hour floor became the 5 minute floor.
  • PAGE_REACH_HOURS is 168. 1mo, month to date, previous month, year to date and previous year are greyed out with the reason. The page shows many reads, so it keeps the common reach. A page that shows one read can pass reachHours(entry).
  • The Blocking tab note still names the widest usable range (7 days), not the widest listed preset.
  • This lane left the "collected every N minutes" note off the server page. Its tabs have no single main read, so the page passed no interval (the default is no note). collectorIntervalMs(entry) and sampleIntervalMs were ready for the single-read pages. Lane L2b wired the note (see its section below).
  • Not wired: a stored choice. The server page range stays unstored, as today.

Tests

  • All 100 rows of Fixtures/time-range-cases.json pass in time-range.js: start, end, live, echo text and error code. I also ran the harness with the process zone set to two other zones and got 100 of 100. So the module does not depend on the machine's zone. No fixture value was edited, and no JS versus .NET difference turned up.
  • WebTimeRangeTests is new, with 9 tests, skipped without Node. They cover the fixture run and the picker DOM harness. The harness checks closed, open, preview before Apply, Enter, Esc and focus, outside click and listener cleanup, presets, date boxes, compact, notes, reach from the catalog, and a sliding live range. Two server-page scenarios check a sub-hour range fetched as the whole hour and trimmed, and every preset and period held or refused.
  • Updated: WebServerPageRangeTests. Its source pin now reads PAGE_REACH_HOURS, and the 1 hour refusal became hours=1 with as_of plus a 5 minute refusal. Also updated: web-kept-history-harness.mjs (two new scenarios and an exported holdSpec in its scratch copy), DarlingWebAssetsTests (the new CSS file) and index.html (links the CSS).
  • Results: WebTimeRangeTests, WebServerPageRangeTests, WebRangeKeptHistoryBehaviourTests, DarlingWebAssetsTests, WebHarnessJsTreeCopyTests and WebReadReach* gave 122 passed, 1 skipped. Every Web*, Chart*, AlertHistory*, Census and Ratchet class gave 2257 passed, 80 skipped, 0 failed. Every Stage=Guard class gave 1409 passed. Darling.Tests builds with 0 warnings.
  • Not run: the full suite (CI decides) and a browser check of the popup's look.

For the other lanes

No server change is needed from this lane. The catalog fields the module reads (max_hours and collector_interval_minutes on the hours param) match what the catalog now serves. The other nine pages can mount the picker the same way. Pages that take hours as a number can use picker.window().hours and picker.window().asOf.

Web pages (lane L2b)

Head at push: 814a50f. Nine pages now use the shared web picker. The server page shows the "collected every N minutes" note.

Pages moved

  • Alert History (pages/alerts.js), Fleet Sweeps (pages/sweeps.js) and Job History (pages/job-history.js) mount the full picker. Each reads its reach from the catalog (max_hours, 168 for all three).
  • The four FinOps tabs (database-resources, high-impact, optimization, storage-growth) use one helper, pages/finops/window.js.
  • Custom Views (pages/views.js) and the view editor's default range (js/editor.js, not pages/editor.js) use rollingHoursPicker. The old Custom number box clamped to 90 days. The picker greys out a longer length and says why.
  • Per-panel time overrides in the editor are unchanged. The brief named only the view default.

Module changes (a real gap)

compact only hid the detail text, so R5 had no base to build on. I added rollingOnly, minSpanMs and stepMs to the picker, and rollingOnlyRefusal and minSpanRefusal to time-range.js. New page-range.js holds the shared mount helper, windowOfSpec and the per-server catalog fetch. No grammar rule changed.

Brief items 2 to 5

  • Item 2 (R5): finops/window.js:35-44 mounts the picker Compact and rolling-only. It offers no calendar period and no start-and-end boxes. These are greyed out or refused with the reason: a length under 1 hour, a length that is not whole hours, a since-range and a finished range. Storage Growth is whole days with a 1 day floor, because its heatmap has one column per day. storage-growth.js:25-29 gives it its own 2160 hour reach: the tool takes that for this view (DarlingMcpFinOpsTools.cs:97), while the catalog entry for get_finops says 168.
  • Item 3 (R6): the web Job History page can offer finished ranges. It sends as_of (job-history.js readParams). The tool passes the end into JobHistoryFilter.UntilUtc (DarlingMcpJobTools.cs:131-135). The reader applies it in SQL inside the per-server top-N (DarlingJobHistoryReader.cs:74-88) and again before the cap (:164). No server change was needed. The page cuts only the start (for a range under an hour). The runs are newest first, so a cap never hides a run in range.
  • Item 4 (R8): the alert table keeps 90 days (AlertHistoryRetentionDays, DailySummaryHorizon.cs:66, used by DarlingRetention.cs:115). The page has no "All". Its longest choice is the read's reach of 7 days, because the catalog says 168 and R2 says lists stay at 168. Offering 90 days needs a one-row change in WebReadReach plus the validator call. I did not make it because it contradicts R2 for a list read. It needs a decision.
  • Item 5 (R3): each server tab declares collector in server-tabs.js (9 tabs, same names as the Viewer's table for the 7 shared SQL Server tabs). server.js applySampleNote asks /api/catalog?server= for that collector's actual interval and calls setSampleInterval. Below 3 samples the picker shows "Data here is collected every N minutes." The range is never widened. The single-read pages show no note, because no collector feeds them as one read (alerts, sweeps, FinOps) or the page has no single read (views).

Tests

  • New WebPageRangeTests (7 tests) and page-range-harness.mjs. They cover the interval lookup, the note thresholds, the rolling-only refusals and the whole-hours window. They also cover the sweep and job-run start cuts, and source pins for items 2 to 5.
  • The harnesses now drive the real picker through web-picker-driver.mjs. Job history covers rolling, finished, over-reach and 30 minutes trimmed. FinOps covers options, refusals and popup shape. Storage growth covers days and reach 2160. Alert history covers the greyed-out list and a finished range. I updated JobHistoryPageTests, AlertHistoryRangePageTests, the FinOps page tests and FinOpsPageShellTests to match.
  • Results: FinOps, AlertHistory, JobHistoryPage, FleetSweepWeb, WebTime, WebServerPage, DarlingWebAssets, WebHarnessJsTree, WebRangeKept and WebPageRange classes: 1120 passed, 0 failed, 131 skipped. A second run of Census, Ratchet, DocCommentHygiene, WebReadReach, Web and Chart classes: 2241 run, 3 failures. One was the module-load harness (fixed, rerun green). Two are DocCommentHygieneTests on ViewerServerTab.TimeRange.cs:428, a Viewer-lane file I did not touch.
  • Not run: the Stage=Guard trait, a live PostgreSQL class, and a browser check of the popup's look.

Open points

  • Alert History reach beyond 7 days (item 4) needs a ruling.
  • The Viewer file ViewerServerTab.TimeRange.cs:428 has two opening summary tags and one close. The Viewer lane owns that fix.
  • The Lite and Viewer FinOps and Job History pickers need the same rolling-only and finished-range rules.

Lite (lanes L3a and L3a-fix)

Every Lite time range control uses the shared picker. That covers the server tab toolbar, the four FinOps lists, the object heatmap window, Alert History, Job History and the Settings default range. No reach cap was added (R1). Apply to All, the Compare box and the display-mode box still sit beside the picker.

How a range reaches the readers

The readers keep their shapes (GetHoursBack, GetCurrentWindowUtc, GetChartWindow). LiteTimeRange.WindowFor turns the picker's range into the old triple:

  • A rolling range of whole hours (1h, 4h, 1d, 1w, 1mo, a typed 36h) is still (hours, null, null).
  • Anything else carries its two instants: 5m, 15m, 30m, calendar periods, typed ranges and since. hoursBack is then the whole hours from the start to now, rounded up, at least 1.
  • A live range is resolved on every call, so it slides. If the held range cannot be used for a moment ('Today' just after midnight), the last window that did resolve keeps being read.
  • A drill into a window under 5 minutes is held at 5 minutes, centred.
  • The display zone is the tab's own (Server, Local or UTC). A mode switch only redraws the picker.

Settings

default_time_range_hours is still read and written. An optional default_time_range holds presets and periods the hours key cannot spell (30m, 1mo, previous-week). A whole-hour choice writes the hours key and removes the new key. Any other preset or period writes the new key. When both exist the new key wins. A typed range is never saved.

What the L3a-fix lane changed (R3, R5, R6, R7, R8)

  • FinOps pickers are rolling only (R5). LiteTimeRange.ConfigureFinOpsPicker makes each one Compact. It sets the shared control's RollingUnit to one hour for the four lists and one day for the heatmap (FinOpsTab.xaml.cs). No calendar period, no custom end, no span under the unit. This is the same shared API the Viewer's FinOps tab uses.
  • Job History has a real end bound (R6). GetJobHistoryAsync and GetJobHistoryWithClocksAsync take an optional exclusive windowEndUtc. The SQL applies it in both the base and the per-job stats CTE, before the row cap. The bound is the server-local end widened by one hour, like the start. The exact instant check follows. If the margin hour pushes in-window runs out of the cap, the read repeats with more room until the cap holds only in-window runs. JobHistoryTab passes the end for a finished range (LiteTimeRange.BoundsOf) and null for a rolling or live range. Microseconds and the sort order are unchanged.
  • Alert History reads a start and an exclusive end. New GetAlertHistoryWindowAsync and DismissAllVisibleAlertsWindowAsync, the same shape as the Viewer's. Dismiss All covers exactly the rows the grid shows, in the live table, the archive sidecar and the post-check. GetAlertHistoryAsync (the MCP read) keeps its inclusive as-of end. DismissAllVisibleAlertsAsync(hoursBack) delegates.
  • Alert History's longest choice (R8). RangePicker.SetLongestChoice(LiteTimeRange.AlertHistoryLongestChoice(now), "All"), a span equal to how long Lite keeps the alert log. The archive is deleted 3 months back (RetentionService.ArchiveRetentionMonths, RetentionService.cs:34), so the span is 89 to 92 days, capped at 365. The Viewer uses 90 days for its store.
  • Sample note per sub-tab (R3). LiteTimeRange.MainCollectorFor(tab, subTab) names the main collector of the page on screen. ServerTab.CurrentMainCollector reads the selected headers and refreshes the note on every tab change. MainWindow hands it the server's actual schedule, falling back to the shipped default (SampleIntervalForCollector). Pages with no single sampled source (Overview, Plan Viewer, on-load configuration, Daily Summary, Collection Health) show no note. The range is never widened.
  • Data-start note at every banner site (R7). RefreshWindowTruncatedBannerAsync is Lite's funnel and now feeds ServerTab.FeedDataStart for every relation, keyed by the relation's collector. The XE blocking banner, the Overview blocking lane (through a new DataStartFound event) and the Job History tab (its own picker) feed it too. The picker shows the floor for the page on screen, else the archive's retention edge. No new query. The Collection Log window has its own window and no picker, so it does not feed. A source-scan test pins the five call sites of ApplyWindowFloorToBanner and what each feeds.

Notes

  • servertab-eventwires.baseline.txt lost two wires for the deleted combo and hour/minute handlers. ServerTabHeldRangeTests, JobHistoryDataStartTests and QueriesTabWindowRoundTripTests follow the new shapes.
  • The toolbar's width with the picker's detail text was not checked on a real window.
  • DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks and EveryDocRunClosesTheSummariesItOpens were red on ViewerServerTabTimeRange (the Viewer lane's file) when this lane last ran them.

Lite test plan

  • Lite and Lite.Tests build with 0 warnings.
  • New LiteTimeRangeGapTests (29 tests). They cover the rolling-unit rule, FinOps wiring and Job History with a range that ended before more rows than the cap (all inside the margin hour). They also cover Alert History end and Dismiss All scope, the longest choice, the collector map, the sample interval and the banner-site pin.
  • JobHistory, Alert, FinOps, OverviewBlockingLane, ServerTabHeldRange, RefreshCoordinator, Census and Ratchet classes: 943 passed.
  • Lite.Tests Stage=Guard: 441 run, one failure (CiSelectReplayGuardTests) that passed when re-run alone.
  • The whole Lite.Tests run was not done. CI decides the rest.

Reach from the timing run (lane L4b)

Raw-table trend reads now reach 30 days (720 hours) on the web and in MCP, through the per-read opt-in. Alert History reaches 90 days. Every ceiling is the WebReadReach row, and every validator reads it. A window past it is refused, never shortened.

Read Measured at 30 days New reach
get_blocking_trend 33.6 ms 720 h
get_deadlock_trend 13.8 ms 720 h
get_lock_wait_trend 5.3 s 720 h
get_blocking_stats 6.5 ms 720 h (table only, see below)
get_cpu_utilization 0.62 s 720 h
get_query_heatmap 5.5 s 720 h
get_tempdb_trend 0.90 s 720 h
get_wait_trend 0.70 s 720 h
get_file_io_trend 6.3 s 720 h
get_memory_trend 0.86 s 720 h
get_perfmon_trend 12.5 s, now rewritten 720 h
get_pg_cpu_utilization 65 ms 720 h
get_pg_io_trend 7.4 s 720 h
get_pg_database_trend 2.3 s 720 h
get_alert_history small table, row-capped 2160 h (90 days)

Left at 168 hours, and why

  • get_current_waits_trend: its waiting-task series reads waiting_tasks, which keeps 7 days by default. A longer reach would read empty past a week. The shortest retention wins.
  • get_server_trend: five of its eleven metrics were timed. Six (memory clerks, latch, spinlock, collector duration, tempdb file I/O, file I/O throughput) never reached their series SQL on the timing store. The read has one ceiling for all metrics, so it stays until those six are timed. Raising it is a one-line row change plus one validator call.
  • get_pg_wait_trend: its timing ran on an empty table (0.1 ms), so no measurement.
  • get_pg_query_duration_trend: not timed.
  • get_blocking_stats is raised in the table only. Its MCP tool takes any window length by design (ValidateUncappedWindow), so the page now offers 30 days and the tool is unchanged.

get_perfmon_trend

  • The ":Wait Statistics" arm tested right(object_name, 16) = ':Wait Statistics'. On a compressed chunk that call is a plain filter, so the arm decompressed every row of the counter and dropped it. I reproduced it on a local TimescaleDB 2.30.1 hypertable with the same compression settings (segmentby server_id only, so no equality on object_name can use compression).
  • The arm now tests object_name LIKE '%:Wait Statistics'. EXPLAIN shows it inside the columnar scan's Vectorized Filter, in both arms. The match is the same: the suffix has no wildcard, and NULL still lands in the second arm only. Only the Darling reader changed; the shared helper that Lite and the Viewer use is untouched.
  • Local timing of the first arm over 30 days of seeded data: about 15 ms before, 5 ms after. That is warm and small. The 12.5 s on the large store was mostly first-touch disk reads (36,987 reads, 11 s of read time), and the compressed batches still have to be read. I could not re-time on the large store. Please re-time get_perfmon_trend there before relying on it.
  • A live test runs the shipped SQL and the same SQL with the old predicate side by side over a default instance, a named instance (with its own isolated spike), another object, a NULL object and two near-miss names. The rows are identical and exactly one spike is set aside.

Alert History (item 6)

  • Reach 2160 hours through the opt-in. The query is ORDER BY alert_time DESC LIMIT (DarlingAlertReader.cs, AlertHistorySql), and the tool validates limit and over-fetches by one, so a long window cannot return more than the row limit. The only index on config_alert_log leads with server_id (PgMigrations.cs:3364), so the fleet-wide read is a scan and top-N sort over a table that keeps 90 days. It is small; I did not time it.
  • The web alerts page already took its reach from the catalog. I corrected its stale comment, and the page now offers up to 90 days (greyed out: Year to Date, Previous Year).

Storage Growth (item 7)

  • The server validates storage_growth itself: 24 hours, or 7 to 90 whole days (MaxStorageGrowthHoursBack, 2160). The catalog said 168 for get_finops because its other views take 168.
  • One source now: WebReadReach.ViewMaxHours takes the server's constant, the catalog serves it as view_max_hours.storage_growth on the hours param, and storage-growth.js asks the picker for view: "storage_growth" instead of carrying its own 2160. The catalog's max_hours for get_finops stays 168.

Descriptions and tests

  • The hours_back description of each raised tool says "up to 720 (30 days)", and Alert History says "up to 2160 (90 days...)".
  • Updated: WebReadReachTests (the raised set, a new test that no read reaches past its collector's default retention, the uncapped read kept on its validator), WebReadReachLiveTests (720 answered, 721 refused), AlertHistoryRangePageTests and its harness (catalog stub at 2160, options now include 30 days), WebPageRangeTests and the storage growth harness (reach from the catalog).
  • Ran against a throwaway TimescaleDB: WebReadReach, WebPageRange, PerfmonWaitStatisticsArtifactMcpLive, FinOpsStorageGrowth, AlertHistoryRangePage, Census, Ratchet, DocCommentHygiene, McpPayload, TrendBuckets, TrendPayloadBudget and DarlingMcpTrendTools: 759 run, 1 failure. Then Stage=Guard: 1409 run, 3 failures.
  • The 3 distinct failures are all in Lite-lane files, not mine:
    • DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks: Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs:371 has two opening summary tags. The Viewer file named in item 8 (ViewerServerTab.TimeRange.cs:428) no longer fails; its tags are balanced on this branch, so I did not edit it.
    • CommentFilterAdoptionTests.EveryLinePrefixCommentFilter_StatesTheBoundItRestsOn: Lite.Tests/LiteTimeRangeGapTests.cs is a new file the pin does not list.
    • TsqlConventionGuardTests.TheMemberScan_ReadsEveryDeclarationWhole: Lite/Services/LocalDataService.AlertHistory.cs GetAlertHistoryWindowAsync is a new truncated-range member the pin does not list.
  • Not run: the full suite, Lite.Tests, a browser check, and a re-time on the large store.

Review round 1 (desktop)

Commits f190aaa and 254a8b5 (head) on the branch. 254a8b5 adds Lite.Tests/LiteTimeRangeGapTests.cs to the darling path filter in .github/workflows/build.yml, because CommentFilterAdoptionTests now lists it (CrossAppGuardCiGateTests requires that). One line per finding in the desktop review.

High:

  • H1, a corrupt settings value overflowed TimeSpan. TimeRangeSpec.TryFromId now works out the span in minutes and returns false past the 100-year limit (PerformanceMonitor.Ui/TimeRange/TimeRangeSpec.cs, the unit switch near line 262), and TimeRangePresets.FromLegacyHours returns null past it (TimeRangePresets.cs:51). Lite (LiteTimeRange.FromSettings) and the Viewer (ViewerPreferences.Normalize) then fall back to their defaults. Tests: TimeRangeTests (ids past the limit, the id at the limit, legacy hours up to int.MaxValue), ServerTabHeldRangeTests.ACorruptRangeId_FallsBackWithoutThrowing and the int.MaxValue row of ANonsenseLegacyValue_OpensOnFourHours, ViewerTimeRangeWindowTests.ACorruptPreferenceRange_FallsBackToTheIndexWithoutThrowing.

Medium:

  • M1, Lite pickers in the wrong zone: Alert History and the FinOps pickers now use a new ServerTimeHelper.CurrentDisplayZone as ZoneProvider (AlertsHistoryTab.xaml.cs:68, LiteTimeRange.ConfigureFinOpsPicker). Job History uses JobHistoryTab.JobHistoryZone (JobHistoryTab.xaml.cs): UTC for All Servers, the server's clock for one server (its last read's clock, else its open tab's, else the machine's). Tests: LiteTimeRangeGapTests.JobHistoryZone_IsTheZoneTheGridWordsItsRowsIn_SoYesterdayMeansTheGridsYesterday resolves "Yesterday" in each zone and checks the instants. TheAlertAndFinOpsPickers_ReadInTheDisplayModesZone checks the three display modes.
  • M2, Apply to All: ForBroadcast moved to TimeRangePresets.ForBroadcast in PerformanceMonitor.Ui and removed from the Viewer. Lite's ApplyTimeRangeToAll_Click (ServerTab.TimeRange.cs) calls it with the tab's picker zone, as the Viewer does. The existing ApplyToAll_SendsACalendarPeriod... test now calls the shared helper.
  • M3, Job History "Last Year": both Job History tabs call SetLongestChoice with 365 days, labelled "Last Year" (Lite/Controls/JobHistoryTab.xaml.cs, Viewer/JobHistoryTab.xaml.cs. The spec lives in LiteTimeRange.JobHistoryLongestChoice and ViewerTimeRangeWindow.JobHistoryLongestChoice). The Lite comment that said "1y can be typed" is gone, and the Viewer XAML comment now says 90 days is typed. 90 days stays a typed value (the control supports one extra choice).

Low:

  • L1 (R8): Lite Alert History "All" is 365 days, a constant. Lite deletes archives by whole month and keeps rows older than 3 months (LiteTimeRange.AlertHistoryLongest). The Viewer stays at 90 days, the Darling retention. Test replaced by AlertHistoryLongest_IsAYear_BecauseLiteKeepsRowsPastThreeMonths.
  • L2, stale data-start floor: _probedFloors.Clear() in RangePicker_RangeChanged, SetTimeRange and SetDrillDownTimeRange (ServerTab.TimeRange.cs, ServerTab.DrillDown.cs).
  • L3, Viewer note by top-level tab: new ViewerTimeRangeWindow.MainCollectorFor(tab, subTab) mirrors Lite's sub-tab map (Queries, CPU, Memory, Blocking). ViewerServerTab.CurrentMainCollector() reads the selected sub-tab, and UpdateSampleIntervalAsync now runs inside the reload loop before each tab load instead of once at the top. Every collector name is checked against CollectorScheduleDefaults.
  • L4: deleted LiteTimeRange.AtLeastMinimumSpan, its call in SetDrillDownTimeRange and its test.
  • L5, "Today" in its first 5 minutes (seat ruling): calendar periods are exempt from the 5-minute floor in TimeRangeSpec.TryResolve. Only a zero-length period (exactly midnight) is still refused. Lite's CurrentRange and the Viewer's Window keep their fallbacks for a "since" start in the future. Tests updated: TimeRangeTests, TimeRangePickerControlTests, ViewerTimeRangeWindowTests.
  • L6, text pins: added behaviour tests for H1, M1, L3 and L5. Deleted the two pins that repeat a live DuckDB or PostgreSQL test (JobHistoryTab_PassesTheRangeEndToTheRead_AndTheEndIsExclusive, TheJobHistoryTab_HandsAFinishedRangesEndToTheRead_AndNoLongerTrimsOnTheClient). The remaining pins on tab wiring stay: a ServerTab cannot be built in a test.

Guard failures:

  • Stacked summaries at CorrelatedTimelineLanesControl.xaml.cs:371: the event's own summary now sits above the event, and the "Showing since" doc sits on its method.
  • CommentFilterAdoptionTests: Lite.Tests/LiteTimeRangeGapTests.cs is listed as a stated bound (its IsDocOrDefinition filter counts ApplyWindowFloorToBanner( sites line by line. The bound is that a call inside a block comment on a line that does not start with * or /* counts). The class summary count went from Two to Three.
  • KnownTruncatedRanges: LocalDataService.AlertHistory.cs GetAlertHistoryWindowAsync is listed. It is an expression-bodied method that opens with is { } end, the same shape as IsStale. The text outside the range is two calls with no string literal and no T-SQL.

Shared fixture, which needed the web lane (now done in the web finish round):

  • Darling.Tests/Fixtures/time-range-cases.json: the row "today just after midnight" now expects 04:00Z to 04:02Z, live, echo 2m Oct 8, 12:00 am - Oct 8, 12:02 am (UTC-04:00), no error. TimeRangeFixtureTests passes. WebTimeRangeTests.TheWebModule_RunsEveryRowOfTheSharedFixture failed until wwwroot/js/time-range.js did the same. The web finish round did it, and the test passes.

Test plan:

  • Lite.Tests: LiteTimeRangeGapTests, ServerTabHeldRangeTests, QueriesTabWindowRoundTripTests 77 passed. Stage=Guard 441 passed. Census, Ratchet, DocCommentHygiene, McpPayload and WebReadReach classes 58 passed.
  • Darling.Tests: Stage=Guard 1409 passed. Census, Ratchet, DocCommentHygiene, McpPayload, WebReadReach, TimeRange and Viewer classes 4452 run, 1 failed (the shared fixture row, above), 338 skipped (live PostgreSQL).
  • Not run: a live PostgreSQL rig (none of this work touches a store), the full Lite.Tests suite, a visual check of the Lite tabs.

Review round 1 (web)

Head 9a127c2. One line per finding, High to Low.

  • H1 Storage Growth floor. pages/finops/storage-growth.js now passes minSpanMs: 7 * DAY_MS. So "Past day" and 2 to 6 days are no longer offered. The read answers 24 as its 30-day default and refuses 2 to 6 days. The pin in FinOpsStorageGrowthWindowBehaviourTests now lists "Past week,Past 30 days", and the harness checks that 1d and 3d are refused with "at least 7d".
  • H2 Alert History trim. pages/alerts.js builds one request (readRequest) and cuts the reply to the exact start with trimAlertsToStart. A 5 or 30 minute range, or Today at 10:30, now lists only alerts inside it. Dismiss All acts on that list. New harness scenario trim (rows 1, 20 and 90 minutes old) is pinned in AlertHistoryRangePageTests.
  • M1 retention. get_query_heatmap goes back to 168 h (raw query_stats is dropped at four days on a TimescaleDB store). New census NoRawTierTableRead_ReachesPastTheTimescaleRawRetention fails when a non-rollup read of one of the three raw-tier tables is raised. Every read raised past 168 h was checked on both store types. The other raw tables are listed here: (blocked_process_report, deadlocks, wait_stats, dmv_blocking_snapshot, cpu_utilization, tempdb_stats, file_io_stats, memory_stats, pg_cpu_utilization, pg_io_stats, pg_database_stats) keep 30 days on both (the existing collector-retention census) and are not in the TimescaleDB raw tier. The three duration trends reach the rollups' 90 days, alert history the alert table's 90 days. So 720 h stays for those and nothing else needed lowering.
  • M2 end in the future. readWindow(range, nowMs) in time-range.js treats a fixed range ending at or after now as a live range ending now. It sends no as_of, sets live: true and counts hours to now. The picker, windowOfSpec and the server page's holdSpec use it. The shared fixture row ("same date today counts as not in the future") is untouched, so the parser parity holds. Harness case futureEnd.
  • M3 live end. time-range-picker.js remembers the end box value it filled in for a live range. Moving only the start writes " - now" (a since-range), and typing an end still gives a fixed pair. Harness case liveEnd.
  • M4 per-tab reach on the server page. Not done in this round. Ruling R2 covers it, and it is done in "Review round 1 (M4)" below.
  • M5 longest choice. The picker takes extraPresets and setExtraPresets, and exposes reachHours(). pageRangePicker({ offerReach: true }) (Alert History) and rollingHoursPicker (views, view editor) add the read's reach as the longest quick choice when it is past 30 days. So Alert History lists "Past 90 days". AlertHistoryRangePageTests expects it.
  • M6 descriptions. New test EveryRaisedTool_HoursBackDescription_NamesItsRowsNumber_AndNoOtherToolClaimsAHigherOne reflects each tool's hours_back description. A read past 168 h needs the words "up to ". No tool claims more than its row. get_blocking_stats is skipped because it never capped.
  • L1 dead option. Removed dataStartMs and setDataStart from the picker (no web page used them). dataStartNote stays in time-range.js.
  • L2 catalog cache. serverCatalog in page-range.js drops a failed fetch from the cache and expires a good one after 5 minutes. The finish round makes that expiry a check on lookup, see below. applySampleNote in server.js has a .catch.
  • L3 unresolvable live period. alerts.js shows "This range cannot be read yet: ..." instead of reading 24 h under a "today" label. server.js rangeContext keeps the held range and labels the shown window, instead of deleting the range.
  • L4 doc. McpHelpers.ValidateWindow doc now names get_alert_history as the list that passes its retention.
  • L5 order pin. WebPageRangeTests asserts the applySampleNote(); call exists before comparing positions.
  • L6 live test. WebReadReachLiveTests asserts there is no error key at 720 h (instead of "no exceeds-maximum text").
  • L7 trim window and sweeps. job-history.js builds the window once (currentWindow()), and the request and the trim share it. /api/sweeps (DarlingFleetSweepEndpoints.cs) now takes its ceiling from the get_sweep_reports row, the number the sweeps page reads from the catalog.

Brief items: get_perfmon_trend stays at 7 days: 11.2 s cold at 30 days on a large store. #5574 tracks the fix. Its row, validator comment, description and tests are back at 168. The comment cites 11.2 s cold, the LIKE saving of 1.3 s, grouping by server only and #5574. get_alert_history keeps 2160 h (the re-time read 148 ms. Cited in the alert tool and the WebReadReach table).

Test runs for this round are listed in "Review round 1 (web, finish)" below.

Review round 1 (web, finish)

Head c0c2191 (the commits before it are the first web round). Four changes and the runs the first round did not finish.

Changes:

  • R9, Today just after midnight. resolveSpec in wwwroot/js/time-range.js now exempts a calendar period from the 5 minute floor. It refuses one only when it has zero length (exactly midnight), the same rule as TimeRangeSpec.TryResolve. Typed spans, "since" and custom start and end keep the floor. The shared fixture row "today just after midnight" now passes in the web module, so the desktop section's "needs the web lane" item is closed. The doc comments of currentLength, resolveSpec, windowOfSpec, windowResultOfSpec, the picker and Alert History no longer say "first five minutes".
  • Callers of an unresolvable period. The Alert History message ("This range cannot be read yet") and the server page's kept-range label (server.js, not edited here) now only apply at exactly midnight. They still work: windowResultOfSpec returns the refusal and the page shows it. A new check in WebPageRangeTests pins three things. Today two minutes in reads 1 hour. Exactly midnight is refused with the 5 minute message. A typed 3 minute span still gets too_short.
  • Why WebTimeRangeTests took 10 minutes. Two of its nine tests drive the kept-history harness, and that harness exited only after 300 seconds each. The cause was in the page code, not the harness. serverCatalog in page-range.js set a 5 minute setTimeout to expire a cached catalog, and a pending timer keeps Node alive. The cache now stores when the catalog was read and drops it on the next lookup after 5 minutes, with no timer. The class now runs in about 10 seconds (9 tests), down from 606 seconds, with no assert changed. A new check in WebPageRangeTests drives a fake clock. Two lookups read once. A lookup 4 minutes later still reads once. A lookup 6 minutes later reads twice. The source has no setTimeout(() => serverCatalogs left.
  • Job History pin. JobHistoryPageTests.ThePageReadsGetJobHistoryThroughTheSharedReadWithASignal failed on the branch: the page now calls readParams(w) and the pin still looked for readParams(). The pin follows the page.

Test plan (Darling.Tests, after the rebase onto the branch head, all on a throwaway PostgreSQL store with TimescaleDB on a local port, now stopped)

  • WebTimeRangeTests, WebPageRangeTests, WebReadReach* (the live class ran, not skipped), WebServerPageRange*, TimeRangePicker*, PageRange*: 53 passed.
  • AlertHistory*, JobHistory*, Sweep*, Views*, FinOpsStorageGrowthWindow*, FinOpsWindowPicker*, McpPayload*: 849 run, 1 failed (the Job History pin above), fixed and JobHistoryPageTests re-run clean.
  • Census*, Ratchet*, DocCommentHygiene* and JobHistoryPageTests: 588 passed. Stage=Guard: 1409 passed. DocCommentHygiene is clean now (the first round saw one failure in a Lite file that the desktop round fixed).
  • Build: 0 warnings, 0 errors.
  • Not run: the full Darling.Tests suite, Lite.Tests (nothing in Lite changed in this round), a browser check of the picker.
  • Timing against origin/dev was not possible: the picker harness and WebTimeRangeTests do not exist there.

Review round 1 (M4)

Head 3b2d246. The code for M4 is pushed. The three tests that failed on it are fixed. See "Review round 1 (finish)".

What changed

  • Each server-page tab lists the windowed reads it makes in reachReads (read names only, in server-tabs.js, and in analysis-findings.js for the Recommendations tab). readsReachHours in page-range.js turns that list into the tab's reach: the smallest catalog max_hours of its reads. A read with no catalog row, a tab with no list and an unreadable catalog all give 168 hours.
  • server.js no longer has PAGE_REACH_HOURS. The reach comes from the catalog through tabReach. The picker gets the tab's reach once the catalog answers, and longer choices are greyed out with "This tab reads up to N days."
  • A range carried over from another tab that is longer than this tab's reach is held. It is read at the tab's own reach, which is the last stretch of the range, ending where it ends. The page says so: the panel label reads "last 7 days of past 20 days", and a notice says the range is longer than the tab reads. It is never dropped silently. The old code deleted such a range.
  • If a wide range is held and the server's catalog has not been read yet, the panels wait for the catalog. They then read once at the tab's reach, instead of reading 7 days and then the rest.
  • The tab note's number ("This page shows at most N days") now follows the tab's reach.
  • Fixed the stale comment about "Today in its first five minutes" (R9 now refuses a calendar period only at exactly midnight).

What this delivers

Most tabs mix a list read (168 hours) with trend reads, so the smallest wins and they stay at 7 days.

Only the SQL Server I/O tab (file I/O and tempdb trends, both 720 hours) offers 30 days. The Activity tab (perfmon trend) and Queries tab (query heatmap) stay at 7 days. This follows the brief's "smallest of its reads" rule. On the Waits tab "Past 30 days" is still greyed out, because Latch Stats and Spinlock Stats take only 7 days. This is the intended reading. A tab's reach is the smallest max_hours among its reads, so Waits stays at 7 days because Latch Stats and Spinlock Stats read 168 hours.

Tests added or changed

  • WebServerPageRangeTests now holds these:
    • The I/O tab offers 720 and Activity does not.
    • Every tab's reach equals the smallest row in WebReadReach for its declared reads.
    • A tab that makes a windowed read it did not declare fails.
    • A 20-day and a 30-day range carried to Wait Stats is read at 168 hours with the notice.
    • A wide range on an unread catalog reads once at 720 hours after the catalog.
    • No tab, analysis-findings.js or server.js types a reach as a number, and every declared read name is a read the web serves.
  • The shared offeredRanges harness scenario now serves the real catalog, refuses per read, and skips presets longer than a tab's reach. ServerPageTabsTests pin follows tabNote(tab, tabReach()).
  • Ran: WebServerPageRangeTests, ServerPageTabsTests, PageRange*, TimeRangePicker* on the rebased head: 50 tests, 3 failed (below). Not run: Census, Ratchet, DocCommentHygiene, Stage=Guard, the other classes in the brief.

Still failing

None. The three failures are fixed in the finish round below.

Review round 1 (finish)

Head 0be679e.

  • Reach scans. NoTab_FetchesTheSameReadTwice and TheQueryStoreTopGrid_DrawsTheApproximationNote_AsAFurtherNote now read server-tabs.js through ServerTabsJsWithoutReachReads (ServerPageTabsTests.cs:60, used at :143 and :1053). It blanks only the reachReads: [...] lists, so a read a tab really makes still counts twice. The M4 test that fails on an undeclared read is unchanged.
  • Sample interval pin. null for a tab with no collector is right. collectorIntervalFromCatalog already answers null for a missing collector, and a null interval gives no sample note and never widens the range. The pin at WebPageRangeTests.cs:156 now names the whole tab.collector ? ... : null call, so a rename or a move still fails it.
  • Waits tab. Stays at 7 days (smallest max_hours of its reads, and Latch Stats and Spinlock Stats read 168 hours). Said in one line in the M4 section above.
  • Midnight message. A calendar period of zero length now says "{Name} has only just started. Pick another range." (time-range.js:442, TimeRangeSpec.cs:208), and the desktop tooltip uses the same words (TimeRangePicker.xaml.cs:373). Typed spans, "since" and custom ranges keep their text. Two fixture rows hold both front ends to the words (time-range-cases.json:981 and :993, new optional errorMessage field read by time-range-harness.mjs and TimeRangeFixtureTests). Lite and the Viewer project build with 0 warnings.
  • Red-plants. R9: removing the calendar exemption from the 5-minute floor in resolveSpec fails TheWebModule_RunsEveryRowOfTheSharedFixture ("today just after midnight") and ThePureParts_RunUnderNode. I restored it. Old midnight text on both sides fails both new fixture rows on the C# side and the shared-fixture test on the web side, then restored.
  • One more fix. ServerPageTitlePinTests.TheRouteKey_StaysTheIdentity_OnlyTheHeadingChanges (a Stage=Guard test) pinned the old mount(main, [...]) list, and M4 added reachSlot (ServerPageTitlePinTests.cs:85).
  • Runs on the final head. One run covered ServerPageTabs*, WebServerPageRange*, PageRange*, TimeRangePicker*, WebTimeRange* and TimeRangeSpec* (the fixture and model classes). It also covered AlertHistory*, JobHistory*, Sweep*, Views*, the two FinOps window classes, McpPayload* and WebReadReach* (live class too, on a throwaway cluster). The Viewer time-range classes, every Census and Ratchet class and DocCommentHygiene ran with it. Result: 1643 passed, 0 failed. Stage=Guard: 1409 passed, 0 failed. Lite time-range classes: 89 passed, 0 failed.

Review round 2

Head a02545a9b. The branch now holds a real merge of the current dev (two XAML conflicts, in Lite/Controls/ServerTab.xaml and Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml). Dev only added a screen-reader name to the old time range combo. The shared picker replaces that combo, so the picker keeps the same name (AutomationProperties.Name="Time range"), and ServerTabToolbarAndTooltipsTests now checks RangePicker for it.

  • H1 wwwroot/js/pages/alerts.js:446. trimAlertsToStart read the server's alert_time with Date.parse. That time has no zone, so the browser read it as local time and every alert was shifted by the browser's offset. It now uses parseUtc from util.js. Test: AlertHistoryRangePageTests.ARangeShorterThanTheWholeHoursItFetches_ListsOnlyTheAlertsInsideIt, which now runs in UTC, Europe/Berlin, America/New_York and Asia/Tokyo with rows in the server's real shape (seven fraction digits, no Z). Node on Windows ignores a TZ variable set before it starts, so the harness takes the zone as a fourth argument and sets process.env.TZ itself. The test also checks the offset's sign, so it cannot pass by running in UTC four times. I checked the other pages this PR touches. Job History sends run_time through FormatEffectiveStart. Sweeps read swept_at with AsUtc. Both carry a Z, so they were already right.
  • M1 wwwroot/js/time-range.js (resolveSpec) and PerformanceMonitor.Ui/TimeRange/TimeRangeSpec.cs (TryResolve). A fixed range that starts after now is refused with "That range has not started yet." (code range_not_started). The review said typed text cannot reach this, but a typed range with a year ahead does ("Dec 25 2030 - Dec 26 2030"). The desktop also accepted it, so both sides now refuse it. The refusal sits in the one resolver, so the picker's preview and Apply (refusalFor), the server page's holdSpec and both desktop hosts all see it. Tests: a new row in Fixtures/time-range-cases.json (run by WebTimeRangeTests.TheWebModule_RunsEveryRowOfTheSharedFixture and TimeRangeFixtureTests), the picker harness case futureStart (preview text, Apply off, nothing held), and WebServerPageRangeTests.AFixedRangeThatStartsInTheFuture_IsRefusedByTheHold_AndTheHeldRangeStays.
  • L1 pages/server.js:198-202. rangeContext passes its nowMs to readWindow and works out the "aggregate from" note from w.endMs (the end that is read). The change is covered by the existing server page range tests, which pass on the fixed clock.
  • L2 pages/server.js:382. A set of servers being waited on (catalogWaits) means a redraw during the catalog wait keeps the loading strip and adds no second wait. The one callback redraws whichever tab is current when the catalog arrives. Test: WebServerPageRangeTests.ARedrawDuringTheCatalogWait_ReadsEachPanelOnce holds the catalog back, renders the same server twice, releases it, and counts panel builds (1). Identical reads in flight are already joined by apiGetJoined, so the URLs alone cannot show the double redraw. The build count does.
  • L3 pages/finops/window.js:31-46 and pages/finops/storage-growth.js:195. finopsWindowControl passes offerReach, and Storage Growth asks for it, so "Past 90 days" is one click again (the page always offered 7, 30 and 90). FinOpsStorageGrowthWindowBehaviourTests now pins "Past week,Past 30 days,Past 90 days".
  • L4 PerformanceMonitor.Ui/TimeRange/TimeRangePresets.cs:70. The CurrentLength comment now says it is null only at zero length (exactly midnight for Today).
  • Merge fallout TimeRangePickerControlTests.cs and ViewerTimeRangeGapFixTests.cs start STA threads, which dev's new WpfStaGateCensusTests (Lite test flake: ScreenReaderNamesFollowUpTests hits a WPF dictionary from two threads #5559) requires to take WpfStaGate.Enter() first. Both helpers now do.

Red-plants: each test fails with the fix removed and passes with it back.

  • H1 with Date.parse restored fails Berlin, New York and Tokyo. UTC still passes, which is why the zones matter.
  • M1 with the web refusal removed fails the web fixture, the picker test and the hold test. With the desktop refusal removed it fails the desktop fixture row.
  • L2 with the guard removed shows 2 builds and fails the new test.

Runs on the final head, against a throwaway PostgreSQL on port 55548 (stopped afterwards):

  • Darling.Tests, the classes in the brief, plus TimeRangeFixture*, ViewerTimeRange* and ServerTabToolbar*. The set covers AlertHistory*, ServerPageTabs*, WebServerPageRange*, PageRange*, the time range classes, JobHistory*, Sweep*, Views*, the two FinOps window classes, McpPayload* and WebReadReach* with the live class: 1193 run. The first pass had one failure, the new fixture row, because that build still held the planted desktop change. After a rebuild the fixture and model classes passed (121 tests).
  • Lite.Tests time range classes (LiteTimeRangeGap*, ServerTabHeldRange*, TimeRangeSlicerLabels*, anything named TimeRange): 81 run, 0 failed.
  • Census, Ratchet and DocCommentHygiene classes: 570 run, 0 failed.
  • Stage=Guard: 1411 run, 0 failed.
  • Lite and Darling.Tests build with 0 warnings.
  • The whole Darling.Tests suite was not run (not run on this machine).

Review round 3 (the 8 Darling tests that failed in CI)

Head 3f313e4, merged with origin/dev (the merge conflicted in four tab constructors, AlertsHistoryTab and JobHistoryTab in Lite and in the Darling viewer: dev added ColumnFilterScope.SetServer, this branch added the picker setup; both kept).

What each failure was, one line each:

  1. TheWebServerPage_DrawsNoChartFromTheCollectionLog (expected 2, got 4): real regression of the PR's shape, not a pin to move. The reachReads lists on the tab objects in server-tabs.js name "get_collection_log" twice more. Fixed in the page (see below).
  2. TheWebSection_ReadsTheToolOnce_... (get_query_store_clutter named twice): same cause, the Queries tab's reachReads list. Fixed in the page.
  3. TheWebPanels_DeclareTheNoteKey_AndTheHostsPassTheRegistry: same cause. The test takes the first "get_blocking" in the file, and that was now the name in the Blocking tab's reachReads, not the panel. Every panel still declares separately_monitored_note. Fixed in the page.
  4. 15 hours_back parameter lines over their budget ceilings, and 5. the 197,994 byte total: real growth the budget forbids. The parameter lines are back to Hours of history. Default N. (the pinned ceilings are unchanged, no ceiling raised). For the 7 tools that already have a reading guide (get_alert_history, get_pg_cpu_utilization, get_pg_io_trend, get_pg_database_trend, get_query_duration_trend, get_procedure_duration_trend, get_query_store_duration_trend) the reach sentence is now the first sentence after the tool's <<GUIDE>> marker, so get_tool_guide serves it. The other 8 (get_blocking_trend, get_deadlock_trend, get_lock_wait_trend, get_cpu_utilization, get_wait_trend, get_tempdb_trend, get_memory_trend, get_file_io_trend) have no guide; adding a marker would put the pointer sentence " Reading guide: get_tool_guide." (31 bytes each) into every tools/list answer, which leaves the total over the ceiling (about 197,711 against 197,466). So they state no reach; a request past it is still accepted up to the row's number, and a request past that is refused with a sentence naming the ceiling. Total now 197,463 bytes, ceiling 197,466.
  5. and 7. TheActivityTab_... and TheWaitStatsTab_...: the Between markers need label: and build: to be neighbours. Same cause as 1 to 3, fixed in the page. The markers are untouched.
  6. NoPostgresWindowPanel_OptsOutOfTheNote: same cause (the first "get_pg_..." name in the PostgreSQL registry was in a reachReads list).

The fix in the page: the reachReads and collector declarations moved out of the tab objects into wwwroot/js/pages/server-tab-reach.js, keyed by tab id, and server-tabs.js applies them once at load (withTabReach(SERVER_TABS, SQL_TAB_REACH)). The tab objects still carry both at run time, so server.js is unchanged. server-tabs.js now differs from dev only by the import and a six-line block with two calls. Ruling R2 (names, never hours) is unchanged: the file holds names only.

Why the lane runs missed these: they ran the time-range classes, but the pins that read server-tabs.js as text and the MCP budget pins live in classes with other names. Step 3: I took every Darling.Tests class whose file mentions a file this PR changed in the web pages, the web endpoints, the MCP tool sources or McpHelpers (218 classes) and ran them. That found two more failures nobody had listed, both mine:

  • WebPageRangeTests.EveryServerTabThatDeclaresACollectorNamesARealOne_... matched collector: on the tab objects. It now reads the collector from server-tab-reach.js (still 9 tabs, still the Viewer agreeing on the first 7).
  • WebReadReachTests.EveryRaisedTool_HoursBackDescription_... required the number in the parameter line, which contradicts item 4. Renamed to EveryRaisedTool_StatesItsRowsNumberInTheGuideTail_NeverInTheHead_AndNoToolClaimsMoreThanItsRow: the head states no reach above 168, the 7 guided raised tools state exactly their row's number after the marker, and no tool claims more than its row.

Also updated: WebServerPageRangeTests (reads the lists from the new module; asserts server-tabs.js has no reachReads:), ServerPageTabsTests (its strip-the-lists helper is no longer needed).

Test plan (r3)

  • The 7 failing classes plus *TimeRange*, *WebTimeRange*, *Census*, *Ratchet*, *DocCommentHygiene*, on the final head: 910 passed, 0 failed, 4 skipped (live PostgreSQL, no rig).
  • Stage=Guard on Darling.Tests: 1432 passed, 0 failed, 0 skipped.
  • The 218 classes from step 3, in three runs: 1411, 920 and 911 tests, 0 failed (107 skipped for live PostgreSQL).
  • Lite.Tests: *TimeRange*, ServerTabHeldRange, JobHistoryDataStart, QueriesTabWindowRoundTrip, RefreshCoordinator, McpToolGuide: 207 passed, 0 failed.
  • Darling.Tests and Lite.Tests build with 0 warnings, 0 errors; git merge-base --is-ancestor origin/dev HEAD true before the push.
  • The moved MCP sentences pass the plain-English checker.
  • No live PostgreSQL run (no rig; all of these are source-text and Node-harness tests).
  • The whole suite was not run (laptop rule).

What to double-check: the 8 trend tools with no guide no longer state their 30-day reach anywhere in the MCP descriptions; the choice was that or a larger tools/list. If you would rather give them a guide, the total ceiling has to move by about 250 bytes.

Review round 4 (the guide-tail pin)

Cause: round 3 moved each guided tool's reach sentence ("hours_back reaches N ...") to the very start of the text after the <<GUIDE>> marker. McpToolGuideHeadsTrendTests.QueryStoreDurationTrend_TailKeepsItsOwnRoutingDetail_DarlingSide requires that tail to start with the tool's own first sentence, so it failed in CI. The pin is right, so the code changed.

Fix: in all 7 guided tools (get_alert_history, get_pg_cpu_utilization, get_pg_io_trend, get_pg_database_trend, get_query_duration_trend, get_procedure_duration_trend, get_query_store_duration_trend) the reach sentence now comes right after the tool's first sentence. The text only moved, so the byte count is unchanged and the tools/list total stays at 197,463 of 197,466. WebReadReachTests still finds each number after the marker and none in the head. The tails still end with the baseline sentence.

Checks on head b92e3ed: the *McpTool*, *WebReadReach*, *ToolGuide*, *Census*, *Ratchet* and *DocCommentHygiene* classes ran 786 tests, 0 failed, 3 skipped. Stage=Guard ran 1432, 0 failed, no FATAL ERROR. Build: 0 warnings. Lite is unaffected (no Lite or shared guide text changed). The [FATAL ERROR] Foreground threads failure in the guards job is #5596, left alone.

…icker reads (#5562)

ValidateWindow gets an overload that takes the read's own ceiling and refuses beyond it. The query, procedure and Query Store duration trends opt in at 90 days; every other read stays at 168 hours. WebReadReach is the one table of reach and main collector per windowed read, served on the catalog's hours param as max_hours, shape and the collector's interval.
…page's use of it (#5562)

time-range.js is the web twin of the shared model and runs all 100 rows of the shared fixture under Node. time-range-picker.js is the control: presets, calendar periods, a typed range that previews before Apply, a date and time pick, reach greying and the sample-interval and data-start notes. The server page swaps its preset list and datetime-local boxes for it; ranges under an hour are fetched as the whole hour and trimmed to the exact pair.
The server tab toolbar, the five FinOps lists and the object heatmap, Alert History,
Job History and the Settings default range now use TimeRangePicker. A rolling range of
whole hours still reaches the readers as (hours, null, null); sub-hour spans, calendar
periods and typed ranges carry their instants. default_time_range_hours keeps working
through FromLegacyHours and an optional default_time_range holds the other presets and
periods. The picker's data-start note takes the Queries probe the tab already awaits and
its sample-interval note the wait stats schedule. Lite's ApplyThemeToCalendar is gone.
…ts cover the rolling-only rule, the job and sweep ends and the server page note (#5562)
…nished range in the SQL, every banner feeds the data-start note, Alert History's All is its retention (#5562)
H1: an id or legacy-hours value past the 100-year limit no longer overflows TimeSpan (startup/tab crash from a corrupt settings file).
M1: Lite Alert History, Job History and FinOps pickers read typed times and calendar periods in the zone their grid uses.
M2: Apply to All resolves a calendar period once (ForBroadcast now lives in PerformanceMonitor.Ui, used by Lite and the Viewer).
M3/L1 (R8): Job History "Last Year" (365d) on both desktops; Lite Alert History "All" is 365d.
L2: Lite clears probed data-start floors when the range moves.
L3: the Viewer's sample-interval note follows the sub-tab on screen and updates inside the reload loop.
L4: removed the unreachable AtLeastMinimumSpan.
L5 (ruling): calendar periods are exempt from the 5-minute floor.
L6: behaviour tests for H1 and M1; dropped two text pins that duplicate live tests.
Guard: stacked summaries, CommentFilterAdoption and KnownTruncatedRanges pins.
…orage Growth floor, live end, perfmon and heatmap back to 168 h, reach descriptions pinned (#5562)
…hat held Node open for 300 s per harness run; pin Today's floor exemption and the cache lifetime (#5562)
…onger than the tab reads is cut with a notice (#5562 review r1 M4)
… on the web and in the desktop picker; the two read-name scans skip reachReads and the interval pin follows the null guard (#5562 review r1 finish)
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 8, 2026 19:25
…cker

# Conflicts:
#	Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml.cs
#	Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs
#	Lite/Controls/AlertsHistoryTab.xaml.cs
#	Lite/Controls/JobHistoryTab.xaml.cs
…de tail, with the reach sentence right after it (#5562)
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) October 8, 2026 23:45
@erikdarlingdata
erikdarlingdata merged commit 4b50c1b into dev Oct 9, 2026
25 of 26 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/5562-time-range-picker branch October 9, 2026 00:06
erikdarlingdata added a commit that referenced this pull request Oct 9, 2026
…kDB memory limit

Release checklist step 2b. README.md already covered everything up to #5491. This adds the three user-facing 3.10.0 changes merged after it: the one time range picker (#5570), the Excel-style text column filters (#5577) and Lite's DuckDB memory limit setting (#5553). Collector count, tab lists, edition table, platform notes, permissions and the deprecation callout needed no change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant