Skip to content

MCP: four Lite tools say where their data starts, and the Queries tools print the window's start in UTC in both apps - #5004

Merged
erikdarlingdata merged 5 commits into
devfrom
feature/4966-lite-mcp-a
Oct 3, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
feature/4966-lite-mcp-a

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Part of #4966 (the first group of Lite MCP tools). The other time-ranged Lite tools, and the Darling twins of these four, come in later PRs.

Why

A time-ranged read that says "the last 7 days" can be served from far less: a lowered retention_days, a server added part-way through the window, or a window that starts before the archive's first month. On the four tools below the reply looked the same either way, so an empty left edge read as a quiet server. The Queries tools (get_top_queries_by_cpu, get_top_procedures_by_cpu, get_query_store_top) already say where their data starts; these four did not.

What changes

  • The rule this PR applies: a field that describes the window (effective_start, oldest_returned_collection_time) prints as UTC with a trailing Z through McpHelpers.FormatEffectiveStart, a note that names the same instant uses the same text, and row times keep the store's form.
  • One helper, McpQueryTools.WindowNotice, beside IsWindowTruncated. From the window-floor probe it builds three keys. effective_start is where the served data starts, never earlier than asked. window_truncated is true when the data starts more than 90 minutes after the window's start. truncation_note is the Queries tools' sentence, naming the table that was read, and is null when the window is covered. The three keys are always present and sit right after hours_back.
  • get_active_queries (query_snapshots) and get_waiting_tasks (waiting_tasks) read the floor by coverage, so a quiet start is not called a cut. A quiet start is a collector that runs from the window's start whose first row comes late. truncated is still the page cut, and oldest_returned_collection_time still says where the page's rows stop.
  • get_query_store_regressions (query_store_stats) checks the floor against the baseline's start, the earlier of the two windows it compares. When the baseline is short, the note adds that it holds only the part from effective_start to baseline_end.
  • get_query_heatmap (query_stats, the view its cells are read from). The cell cap stays under truncated.
  • No effective_hours_back on any of the four. Each payload carries a page cut under the bare word truncated, and McpPayloadContractCensusTests (TheWindowFloor_IsSpelledWindowTruncated_BesideItsReach_AndNeverBareTruncated_OnBothSkus) fails that key in the same block as a bare truncated. effective_start is the reach.
  • Census rosters, updated on purpose in Darling/Darling.Tests/McpPayloadContractCensusTests.cs: the window_truncated entry in SecondBoundCutKeys and the truncation_note entry in CutNoteKeys now list McpSessionTools.cs and McpWaitTools.cs beside McpQueryTools.cs, and their descriptions name the four tools. WindowFloorBlocks and WindowFloorTools are unchanged: there is no new effective_hours_back writer, and no description changed.
  • Not changed: no tool description and no McpInstructions text (the description pins compare Lite with Darling, and Darling gets the same keys later). The empty and filtered-miss statuses are as they were. get_waiting_tasks already carried oldest_returned_collection_time and newest_returned_collection_time, in the same form as Darling's, so it needed no parity change.
  • Payload growth, counted from the key text: 97 bytes on a covered window, about 270 on a cut one (about 390 on get_query_store_regressions, which adds a sentence). No response-budget test failed, so none was re-pinned.
  • effective_start now always ends in the Z that marks UTC, on the four tools above and the three Queries tools, through one formatter (McpHelpers.FormatEffectiveStart, in the project both apps share). The instant is unchanged. Before, the Z depended on which instant was printed: the start that was asked for is UTC and printed one, and a floor read off the store is naive and printed none.
  • Darling's half: get_top_queries_by_cpu, get_top_procedures_by_cpu and get_query_store_top (the main payload and the min_dop and module miss hints of each) now write effective_start through the same formatter, so their cut window, which printed the store's floor with no Z, ends in Z like a covered one; the query-store clutter report goes through it too and prints the same text as before. Left as they were, on purpose: the Performance-Trends disclosure (both apps) and get_query_trend print the store's naive form on every path, so effective_start keeps matching the time of the first point beside it.
  • The Query Store interval-table note names the start in the field's text. QueryStoreIntervalWide.HistoryNote now takes the start as text, and each caller formats it with McpHelpers.FormatEffectiveStart: DarlingMcpDataTools.QueryStoreTableNote for get_query_store_top's interval-table route, and DarlingWebEndpoints.QueryStoreHistoryNote for Compose's Query Store panel (its query_store_history_starts prints through the formatter too, so the field and its note agree). Before, the note wrote a plain "o" of the interval table's naive floor, so it carried no Z beside an effective_start that did. The web finds the instant in the note by the field's exact text to show it in the browser's zone, so it drew that note in bare UTC above a grid of local times (the Queries tab at 7 days, on a server whose Query Store interval is over 60 minutes or whose interval table began mid-window).
  • The min_dop miss message of get_top_queries_by_cpu ("from ...") names the floor in the same text as hints.effective_start.
  • oldest_returned_collection_time (where a page's rows stop) prints as UTC with the Z through the same formatter on get_active_queries and get_waiting_tasks, in Lite and in Darling. The instant is unchanged. newest_returned_collection_time and every row time keep the store's form. The web reads either form: the server-side note for a capped list parses it as UTC, and the page composes its text from oldest_shown_utc.
  • A window of 90 minutes or less makes no probe call (Lite). The new McpQueryTools.WindowNoticeAsync asks the coverage probe only when CanWindowBeTruncated says the window is longer than 90 minutes, and the four tools call it. For a short window effective_start is the start that was asked for (the probe could answer the first row inside the window). The page cut (truncated, oldest_returned_collection_time) never used the probe and is unchanged. get_query_store_regressions checks the baseline's start, which is days long, so its probe is asked as before.

Test plan

  • New Lite.Tests/McpWindowNoticeToolTests.cs, 19 tests: 14 on a DuckDB store, 5 on the helper. Each tool gets a floor test: rows start inside the window, so window_truncated is true, effective_start is the seeded floor and the note names the table. Each gets a covered test: an older row exists, so the flag is false and the note is null. Each gets a page-cut test: a filled page still reports truncated: true beside window_truncated: false. The two coverage tools add a quiet-start test, with the collector running from before the window and the first row two days in, and the answer is false. The regressions test also checks that the covered effective_start equals baseline_start.
  • Tool RED: with the three key lines deleted from the four payloads, the 14 tool tests fail (the 5 helper tests still pass); restored, all 19 pass.
  • Roster RED: with the old rosters, EveryCutKey_IsThePageDialect_OrClassified_OnBothSkus fails (1 of 68 in McpPayloadContractCensusTests); with the new ones the class passes.
  • Lite.Tests, targeted: 260 passed, 0 failed (McpWindowNoticeToolTests, CrossAppMcpToolInventoryPinTests, McpToolsListBudgetTests, McpReadToolBudgetTests, QueryStoreRegressionsBudgetTests, QueryHeatmapBudgetTests, QueryWindowTruncationTests, McpMissMessageParityPinTests, McpPageContractTests, QueryHeatmapToolTests, QueryStoreRegressionsToolTests, DataStartBannerTests, DataStartBannerProbeFailureTests, and the classes that name the files touched).
  • Darling.Tests, targeted: 303 passed, 0 failed (McpPayloadContractCensusTests, McpToolsListBudgetTests, CrossAppMcpToolInventoryPinTests, DocCommentHygieneTests, McpPayloadClockFrameDisciplineTests, ConsumedTimestampFrameDisciplineTests, McpPageContractTests, McpLatestSnapshotStampTests, McpZeroIsAMeasurementTests, AsOfWindowAnchorTests).
  • effective_start ends in Z on a covered window, a cut window and a floor inside the 90-minute slack, and parses to the same instant as before (McpWindowNoticeToolTests gains 2 tests and a Z check in both shared assertions; the three cut-window tests in QueryWindowTruncationTests read the old no-Z text as local time and were updated on purpose to read the Z): with the formatter set back to a plain ToString("o"), 11 of the 38 tests in those two classes fail, and with it restored all pass. Lite.Tests, targeted at the classes that name effective_start, McpQueryTools, McpSessionTools or McpWaitTools: 246 passed, 0 failed; McpPayloadContractCensusTests: 68 passed, 0 failed.
  • Darling's half: a read of get_top_queries_by_cpu and get_top_procedures_by_cpu over a cut window and over a covered window prints an effective_start that ends in Z (new live test RawTopReads_PrintEffectiveStartInUtc_ForACutWindowAndACoveredOne), McpHelpers.FormatEffectiveStart gives one text for a naive, a UTC and a local-kind instant, and a source pin (EffectiveStartUtcTests) keeps the Darling writes routed. With the three tools' writes set back to a plain ToString("o"), 5 tests fail: the cut-window read expects 2026-10-03T06:36:55.0000000Z and gets the same text with no Z, the pin, and the 3 hourly-routing live tests in TopQueriesHourlyRoutingLiveTests, whose five expected texts were updated on purpose to name UTC because they pinned the old naive form; restored, all pass. Darling.Tests, targeted at the 191 classes that name effective_start, McpHelpers, DarlingMcpDataTools, DarlingMcpQueryStoreClutterTools or RawWindowFloor: 2285 run offline with 0 failed (163 skipped for want of a store), and 2285 run against a throwaway PostgreSQL store with 2282 passed, 2 skipped (they need a second PostgreSQL to read the log of) and 1 failed: TrendPayloadBudgetLiveTests (a PostgreSQL trend tool this change does not touch) hit a dropped connection in the combined run and passed when re-run alone. The meta, pin and census classes of Darling.Tests: 1038 run, 0 failed (17 skipped for want of a store). Lite.Tests, targeted at the classes that name McpHelpers, McpQueryTools or effective_start: 512 passed, 0 failed.
  • Lite.Tests and Darling.Tests build with 0 warnings.
  • The full suites were not run.

Fixes to the window text and the short-window probe

  • Interval-table note. New theory QueryStoreIntervalWideHistoryNoteTests.TheNote_NamesTheStartInTheTextEffectiveStartPrints_ForEveryBound over all 6 WideStartBound values, with a naive start: it failed 6 of 6 against the old builder and passes through both call sites now. WebDataStartNoteTests.TheQueryStoreNote_NamesItsInstantInTheBrowsersZone_... now stamps with FormatEffectiveStart and takes the note from QueryStoreTableNote: it failed at its Assert.Contains(stamp, note) before, and passes now, with the page code run under Node. Live: QueryStoreIntervalWideBelowFloorLiveTests asserts that the note of the filled-since route and of the purge-edge route contains effective_start's exact text, Z included.
  • min_dop miss. TopQueriesHourlyRoutingLiveTests asserts that the empty min_dop page's message contains "still holds (from " plus hints.effective_start plus ";" and that the field ends in Z. With the old {effectiveStart:o} it fails; it passes now.
  • oldest_returned_collection_time. Lite McpWindowNoticeToolTests (a capped 1-hour read of each tool) and the live DarlingMcpSessionToolsLivePostgresTests assert the field ends in Z and is the same instant as the oldest returned row. A source pin, EffectiveStartUtcTests.EveryOldestReturnedWrite_OfTheWindowTools_RoutesThroughTheSharedFormatter (3 cases), keeps the four writes routed. Updated on purpose to the Z form: McpPageContractTests.GetWaitingTasks_EnvelopeCarriesTheWindowAndThePageBounds and the source pin in WebDataStartNoteTests.TheCappedRule_.... New WebDataStartNoteTests.ACappedList_WhoseOldestRowIsStampedUtc_ReadsAsTheNaiveOneDid_OnTheServerAndOnThePage runs a Z-stamped capped answer through the server-side note and the page code (New York zone) and gets the same note, instants and local text as the naive one.
  • Short windows. Lite McpWindowNoticeToolTests: the helper at 60 and 90 minutes makes 0 probe calls and answers covered at the asked start, and at 2 hours asks once; a 1-hour read of get_active_queries, get_waiting_tasks (capped, still naming its oldest row) and get_query_heatmap with the first row 35 minutes in reports effective_start at the window's start.
  • RED for the last three items, with the old shapes put back (plain "o" for the message and the two fields, the probe always asked): Lite 5 of 25 in McpWindowNoticeToolTests fail (helper at 60 and at 90 minutes, and the three tools); Darling 5 of 14 fail (the 3 source-pin cases, the session-tools live test, the min_dop live test). Restored, all pass.
  • Lite.Tests, 19 classes (McpWindowNoticeToolTests, McpPageContractTests, QueryWindowTruncationTests, McpPayloadContractCensusTests, McpToolsListBudgetTests, McpReadToolBudgetTests, the heatmap and regressions tool and budget classes, CollectionLogToolTests, McpLatestSnapshotStampTests, AsOfWindowAnchorTests, MeasurementContractCensusTests, CrossAppMcpToolInventoryPinTests, McpMissMessageParityPinTests, DataStartBannerTests, DataStartBannerProbeFailureTests, McpStatusEnvelopeTests): 234 passed, 0 failed.
  • Darling.Tests without a store, 20 class filters (the history-note, web-note, compose, census, doc-comment, clock-frame, page-contract, tool-list-budget and viewer-banner classes among them): 728 run, 0 failed, 2 skipped. BothSkusToolDescriptions_StayByteIdentical: 1 passed.
  • Darling.Tests live against a throwaway PostgreSQL store with TimescaleDB, 14 classes (the interval-table, hourly-routing, session-tools, web-note, raw-window-floor, compose, Query Store top, filter-semantics and active-queries-budget live classes, plus the history-note, web-note and pin classes): 93 run, 0 failed, 0 skipped.
  • Both test projects build with 0 warnings (the web service project is built as part of Darling.Tests).
  • The full suites were not run.

Not in this PR

  • The other time-ranged Lite tools that do not yet say where their data starts, and the Darling twins of these four (same three keys, same wording), come later. The Darling twins are also where the tool descriptions and McpInstructions can name the new keys, because those pins compare the two products.
  • The empty and filtered-miss statuses of these four tools do not carry the window read yet (the Queries tools carry it only on get_top_queries_by_cpu's min-DOP miss and get_query_store_top's module miss).
  • Left as they were, for the same rule in a later group: oldest_returned_collection_time on get_collection_log and get_plan_corrections (both apps) still prints the store's naive form, and get_plan_corrections has a page-contract test that pins it; the effective_start that the web's own data-start note writes (a naive floor on its coverage path, the tool's own text on its capped path); and the hourly tier's precision_note sentences, which name the served instants with a plain "o".

CHANGELOG

No entry in this PR; the release owner adds it under [Unreleased]. Proposed entry:

SECTION: Added
ENTRY:
- **Lite's `get_active_queries`, `get_waiting_tasks`, `get_query_store_regressions` and `get_query_heatmap` now say where their data starts** ([#5004]) - a window that reaches back past what the store holds (a lowered retention_days, a server added mid-window) used to look like a quiet server. Each reply now carries `effective_start`, `window_truncated` and `truncation_note` beside `hours_back`, with the table that was read named in the note. `get_query_store_regressions` checks the baseline's start, the earlier of its two windows. The page cut (`truncated`) is unchanged, none of the four writes `effective_hours_back`, and a window of 90 minutes or less is not probed and reports its own start. `effective_start` now always ends in Z in both apps, on these four tools and on `get_top_queries_by_cpu`, `get_top_procedures_by_cpu` and `get_query_store_top`, and so does `oldest_returned_collection_time` on `get_active_queries` and `get_waiting_tasks` in both apps: a cut window used to print the store's floor without it. The Query Store interval-table note and the min_dop miss message name the start in the same text. The trend tools still print the store's own time, as their points do.
REF:
[#5004]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5004

…essions and get_query_heatmap say where their data starts (#4966)

Each payload now carries effective_start, window_truncated and truncation_note beside hours_back, from the same
window-floor probe the Queries tools use, so a window that reaches back past what the store holds no longer looks
like a quiet server. get_active_queries and get_waiting_tasks read the floor by coverage (the collector's logged
runs count as well as rows), get_query_store_regressions checks it against the baseline's start, the earlier of its
two windows, and get_query_heatmap reads it from query_stats. All four keep their own page cut under truncated, and
none writes effective_hours_back, which the payload census holds apart for the window floor.
…4966)

The Queries tools and the four window-notice tools (get_active_queries, get_waiting_tasks, get_query_store_regressions, get_query_heatmap) printed effective_start with a Z when the store covered the window and without one when the served start was the floor read from the store (a window cut short, or a floor inside the ninety-minute slack). The requested start is a UTC instant and the floor is a naive one, and a plain "o" prints the zone marker only for the first. One helper, McpQueryTools.FormatEffectiveStart, now sets the kind, and every one of those writes goes through it; the instant is unchanged.

The trend disclosure and the oldest/newest_returned_collection_time bounds are unchanged: both print the store's naive form on every path, so no zone marker comes or goes there.

The cut-window tests in QueryWindowTruncationTests parsed the text as local time and shifted the seeded floor the same way, which only agreed while the cut path carried no Z; they now read the Z and compare the instants directly.
… shared formatter (#4966)

Darling's get_top_queries_by_cpu, get_top_procedures_by_cpu and get_query_store_top
printed effective_start with a plain "o". The requested start is UTC, so a covered
window ended in Z, while a cut window served from the floor read off the store (a
naive instant) printed no zone marker at all. The marker came and went with
window_truncated, on the value a reader most needs to read right.

The formatter Lite already used moves into McpHelpers.FormatEffectiveStart in the
shared Common project. Lite's four tools and WindowNotice call it from there, and
Darling's three tools (the min_dop miss, the module miss and the main payload of
each) call it too. The query-store clutter report's effective_start goes through it
as well; its text does not change, since its own stamp already named UTC.

Left as they were, on purpose: the Performance-Trends disclosure and the query
history tool print the store's naive form on every path, so effective_start keeps
matching the time of the first point beside it.

Tests: the shared formatter gives one text for a naive, a UTC and a local-kind
instant; a live test reads a cut and a covered window through get_top_queries_by_cpu
and get_top_procedures_by_cpu and asserts the Z; a source pin keeps the writes
routed. The hourly-routing live test's five expected effective_start texts now name
UTC, since they pinned the old naive form.
@erikdarlingdata erikdarlingdata changed the title Lite MCP: get_active_queries, get_waiting_tasks, get_query_store_regressions and get_query_heatmap say where their data starts (#4966) MCP: four Lite tools say where their data starts, and effective_start always prints in UTC in both apps Oct 3, 2026
…e window's start in the field's UTC text; oldest_returned_collection_time prints in UTC; short windows skip the probe (#4966)

The Query Store interval-table route of get_query_store_top wrote its truncation_note from a plain "o" of the interval table's naive floor, so the note carried no Z while effective_start beside it did, and the web drew the note in bare UTC above a grid of local times. HistoryNote now takes the start as text; each caller formats it once with McpHelpers.FormatEffectiveStart (the MCP route and Compose's panel, which prints its query_store_history_starts the same way).

The min_dop miss message of get_top_queries_by_cpu names the floor in the same text as hints.effective_start.

oldest_returned_collection_time, where a page's rows stop, prints as UTC with the Z through the same formatter on get_active_queries and get_waiting_tasks in both apps. The newest row's time keeps the store's form.

Lite's four window-floor tools skip the coverage probe for a window of 90 minutes or less (McpQueryTools.WindowNoticeAsync, through CanWindowBeTruncated); the page cut does not use the probe and is unchanged.
@erikdarlingdata erikdarlingdata changed the title MCP: four Lite tools say where their data starts, and effective_start always prints in UTC in both apps MCP: four Lite tools say where their data starts, and the Queries tools print the window's start in UTC in both apps Oct 3, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 3, 2026 09:01
@erikdarlingdata
erikdarlingdata merged commit 3943d78 into dev Oct 3, 2026
24 of 26 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/4966-lite-mcp-a branch October 3, 2026 09:02
erikdarlingdata added a commit that referenced this pull request Oct 4, 2026
… where their data starts (part of #4966) (#5127)

Lite's get_deadlocks, get_deadlock_detail, get_blocked_process_reports, get_blocked_process_xml and get_long_query_completions now say where their data starts, with the contract the other Lite window-floor tools use (#5004, #5015). A data answer carries effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back; an empty answer carries the same three keys under hints, so "no deadlocks" over a 7-day ask no longer reads the same as a store that holds two days; not_collected and the runtime-precondition answers keep their shape.

These are event lists, and a collector's first run can store events from before itself, so the floor on a data answer is the earlier of the coverage probe and the oldest event shown (McpQueryTools.EventWindowNoticeAsync). A window of 90 minutes or less with rows starts no probe; an empty answer always probes. get_blocked_process_reports also counts the DMV blocking snapshots it falls back to; get_blocked_process_xml, whose XML only the Extended Events session supplies, probes that collector alone, through a new optional includeAlsoCovered on GetQueryWindowFloorAsync whose default keeps every other caller unchanged.

Tests: McpWindowNoticeEventToolTests (new): for each tool, a notice when collection starts inside the window, none on a quiet start or a first-run backfill, hints on an empty answer past coverage, the key order, a short window that starts no probe, and a truncated page next to a floor; plus the XML tool's Extended Events floor when only the DMV collector covered the window. The census rosters and the shared-formatter pin cover the two files. On Windows, the eleven Lite classes that touch the window floor pass (343).

Part of #4966
erikdarlingdata added a commit that referenced this pull request Oct 5, 2026
…ction log, plan correction and memory pressure tools, say where their data starts (part of #4966) (#5147)

This lands two changes for #4966: the Darling MCP twins of Lite's data-start tools (first reviewed as #5131), and Lite's config-change, collection-log, plan-correction and memory-pressure tools.

Darling's get_active_queries, get_waiting_tasks, get_query_heatmap and get_query_store_regressions now say where their data starts, with the contract Lite's twins use (#5015): effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back on a data answer, and the same three keys under hints on an empty answer, so an empty or short answer over a window the store never covered no longer reads as a quiet one. not_collected and unavailable answers keep their shape. get_waiting_tasks, the heatmap and the regressions tool always carry the keys, as Darling's Queries tools do; get_active_queries carries them only when the window was cut, by the house rule that a payload omits null and false fields.

A shared helper, DarlingMcpWindowNotice, probes coverage through DataWindowFloor.GetAsync (the registry's created_date and the purge edge, a run counting as well as a row), with Lite's sentences copied and pinned. A window of 90 minutes or less with rows starts no probe; an empty answer always probes; the regressions tool probes from its baseline start. A probe that fails costs the notice, never the rows: it is logged and the answer goes without the keys. The web keeps its own note for the reads it lists (WebDataStartNote strips the tool's keys before deciding); the Active Queries, Query Heatmap and Query Store Regressions panels now show the tool's own sentence, as the Top Queries panels already do.

In both apps, get_active_queries now previews query text at 400 characters instead of 500, so a cut window (keys plus note) stays inside the 32,768-byte response budget: the default call over the budget test's 50 heavy rows is 30,223 bytes covered and 30,504 cut. get_active_queries' and get_waiting_tasks' descriptions now name the three keys in both apps.

Tests: helper tests (the 90-minute rule, the null floor, the exact sentences, a probe that throws, a caller who cancels); live tests for each tool (a notice when coverage starts inside the window, none on a quiet start, hints on an empty answer, a failed probe that keeps the rows); a covered and a cut budget case; the web guard; the census rosters and the shared-formatter pin. The full Darling suite fails only tests on the Mac-only list; Lite's description, budget and guide classes pass on Windows.

Lite's get_server_config_changes, get_database_config_changes, get_trace_flag_changes, get_collection_log (one server), get_plan_corrections and get_memory_pressure_events now say where their data starts, with the contract the other Lite window-floor tools use (#5004, #5015, #5127): effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back on a data answer, the same keys under hints on an empty answer, and not_collected and unavailable answers unchanged. The fleet form of get_collection_log names no server, so it carries no notice.

Each probe reads the source its tool returns rows from: the config snapshots on capture_time with their collector's runs, the collection log over the read's own window, v_plan_correction, and memory pressure on sample_time. None needs the event-time rule, because each probe already reads the column the rows are windowed on. The config change lists diff each in-window snapshot against the one before it, so a snapshot from before the window also counts as coverage, but only when the server was monitored during the window (at least one collection-log run); with no run in the window the answer says nothing was read (LocalDataService.GetConfigSnapshotCoverageFloorAsync). A plan-corrections answer that holds only automatic-tuning rows is probed as a data answer. The one-server collection log now treats a status filter as a filter, so "no ERROR runs" takes the filtered-empty answer rather than the quiet-window one.

Tests: McpWindowNoticeConfigAndLogToolTests (new): for each tool, a notice when collection starts inside the window, none on a quiet start, hints on an empty answer past coverage, the key order, a short window that starts no probe, a capped page where the tool pages; the config tools' pre-window snapshot with and without a run in the window; a status filter that matches nothing; a one-hour automatic-tuning-only answer. The census rosters and the shared-formatter pin cover the four files. On Windows, the three Lite window-notice classes pass (147).

Part of #4966
erikdarlingdata added a commit that referenced this pull request Oct 5, 2026
…ts tools say where their data starts (part of #4966) (#5172)

Thirteen more Lite MCP tools now say where their data starts, with the contract Lite's other window-floor tools use (#5004, #5015, #5127, #5147): effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back on a data answer, and the same keys under hints on an empty answer. not_collected and unavailable answers keep their shape, and a window of 90 minutes or less starts no probe on a data answer. This also carries the work first opened as #5180.

System health: the nine get_health_parser_* tools read system_health_events on event_time, and the coverage probe reads the same column, so an event a first collection stored from before itself is already named by the probe. The probe counts every event type and the collector's runs, so a long-monitored server with a rare event type gets no false notice.

Default trace: the probe converts each candidate row from the monitored server's wall clock to UTC, as the read does. Wait stats: the keys ride on the data answer only. Blocking stats: blocking and deadlocks are separate series in one answer, so the one notice sits at the LATER of the two series' starts; inside the blocking series the blocked process reports and the DMV blocking snapshots stand in for each other, so the earlier of those two is the blocking start. get_daily_summary_range gets no notice: each row carries its data_state and the payload its retention_horizon, and days with no data are omitted rather than shown as quiet.

Tests: McpWindowNoticeSystemHealthToolTests (new) across the nine tools, and McpWindowNoticeAggregateToolTests (new) for the other three plus the daily-summary pin: a notice when collection starts inside the window, a quiet start, backfilled and sparse events, hints on each empty answer, the key order, a short window, capped pages, a failed probe, and for blocking stats each series covered or cut in both orders. Census and EffectiveStartUtcTests rosters list every written file.

Part of #4966
erikdarlingdata added a commit that referenced this pull request Oct 9, 2026
* WEB: Memory Grants, Resource Semaphore, Plan Corrections and the Query Store clutter tiles say where their data starts (part of #4966) (#5144)

The web server page's Memory Grant Pressure, Resource Semaphore Pressure, Plan Corrections and Query Store clutter panels now say where their data starts, so a server with a short history no longer shows a 7-day range as if it were all quiet.

The server adds the note for get_memory_grants and get_resource_semaphore (both probe memory_grant_stats, the table their window aggregates read) and get_plan_corrections (plan_correction). Plan Corrections is a newest-first list, so a capped page names the oldest recommendation it returns, and its "empty" answer also gets the note. The page's shared note strip learns three things: a spec can opt out (windowNote: false), which the newest-snapshot panels use (the two grants lists and Automatic Tuning); a stat tile can draw it as well as a table; and a spec can read the note from a nested block (floorKey), which the Query Store Clutter and Query Store Overhead panels use for the note their tool already returns. The shared fanout's empty path now draws the note as the single-panel path does.

Tests: page scenarios through the shipped util.js, panels.js and server-tabs.js in the Node harness (the opt-out, a stat, the nested note, the fanout's snapshot sibling, an empty Plan Corrections answer with and without the flag, Automatic Tuning quiet); the capped-rule tests extended to Plan Corrections; a live case for a memory read whose rows start inside the window; the admitted status words.

Part of #4966

* Daily Query Store summary for get_query_store_top's long windows: the tables (rung V161) and the hourly builder (#5094) (#5125)

Daily Query Store summary for get_query_store_top's long windows: the tables (rung V161) and the hourly builder (#5094)

Part of #5094. No reader uses the summary yet; the read and its "approximate" flag come next.

- Rung V161 adds two plain tables. collect.query_store_top_daily holds one row per server, UTC day and query identity (database, query id, plan id, query hash, outcome, replica role, module), with sums and non-NULL counts that recombine exactly, so a reader reproduces today's unweighted mean of interval averages. Its unique key is NULLS NOT DISTINCT. collect.query_store_top_daily_built records each built day's pass. The viewer's schema probe gains the V161 sentinel and arm.
- QueryStoreTopDaily is the hourly store-maintenance tick's fifth tenant. Pass 1 builds a day at its end + 2 h, pass 2 rebuilds it at its end + 25 h, and a day first seen past that point is built once at pass 2. Each build is one transaction under a 60 s statement timeout, bounded on first_execution_time to the day (−26 h, +1 h), for enabled servers and days after their coverage claim, oldest first. The tick does at most 60 builds and starts none after 10 minutes; GC follows the interval table's 9-day horizon and disabled servers.
- Approximate by design (Erik's call on #5094): a late write into a built day is missed, a row moving into the next day after pass 1 counts twice until pass 2, and a monitored server clock more than an hour ahead drops rows past the day's end + 1 h. Each case is pinned by a live test.
- On a production store, one build of the largest server's day read about 197k interval rows (30k blocks) into 26k groups in 2.3 s.

* Add a Manage Tags page to the web dashboard (#5085) (#5138)

The web dashboard gains a Manage Tags page, over the server-tag write routes (#5137). It reads the tag forest and each server's tags from the existing `/api/fleet`, so it adds no route, grant or schema change.

**What the page does.**
- **The tree:** up to four levels, with each tag's colour and its server count from the payload.
- **Tag actions:** create a root or child tag, rename, recolour, move under another parent, and delete.
- **Delete:** the page first asks the server without confirming. If deleting would leave custom alert rules matching no server, it names those rules and asks again, then sends `?confirm=true`.
- **Assignment:** a server checklist assigns the selected tag. The page records only the boxes the user changed, so Apply sends one assign for the added servers and one unassign for the removed ones, and never touches a server someone else changed meanwhile.
- **After each write:** the page lists the alert rules whose coverage changed.
- **The 60-second refresh** keeps the selection, the unsaved checkbox changes (marked "(unsaved)") and the open form, including the focused input.
- **In-flight writes:** while a write is running, a second click sends nothing, and Cancel and the form openers wait for it.
- **A parent deleted elsewhere** keeps the form and what was typed, and resets the parent to none.
- **Read-only sign-ins** see the page with every write disabled.

Every server string reaches the page through the house `el()` helper as text. A colour is used in a style only after it matches `#RRGGBB`.

**Tests.** The behaviour tests drive the page under Node with a fake fetch, and check each request's method, path, content type and body:
- the tree and the forms;
- the delete confirm flow;
- the assign and unassign diff, including servers changed elsewhere;
- the refresh keeping the form and the edits;
- double-click guards;
- 403, 404, 409, 415, 500 and network refusals;
- read-only.

Page tests pin the routes and methods.

Part of #5085

* get_read_latency fits its rows to the MCP response budget (#5135) (#5149)

`get_read_latency` returned up to `limit` rows (at most 1,000) and never fitted its response to the shared MCP response budget (`McpResponseBudget.DefaultBytes`, 32,768 bytes). With 1,000 routes, a `limit=1000` answer was 227,445 bytes.

The tool now builds its response the way the availability-group reader does:
- it takes rows in the total order (p95 descending, then count, then surface and route) while they fit;
- it then serializes the real final response, dropping the last row and re-serializing while it is still over the budget.

The answer can therefore never exceed the budget, even by the few bytes the counters add.

**New fields.** The response carries `truncated` (the limit or the budget cut the page), `reads_returned` and `reads_total`. Rows always drop from the end of the order. The guide tail says the page is capped at `limit` rows or the budget, whichever cuts first, and to raise `limit` or narrow with `surface` or `route`. The tool head is unchanged. The web read route returns the same capped answer.

**Tests.**
- A live test seeds 1,000 routes, then asserts the size bound, the three fields, and that the rows returned are the top of the order.
- A unit test sweeps the budget across the last row's edge, and asserts the UTF-8 size never exceeds it.

Fixes #5135

* MCP: Darling's four data-start twins, and Lite's config change, collection log, plan correction and memory pressure tools, say where their data starts (part of #4966) (#5147)

This lands two changes for #4966: the Darling MCP twins of Lite's data-start tools (first reviewed as #5131), and Lite's config-change, collection-log, plan-correction and memory-pressure tools.

Darling's get_active_queries, get_waiting_tasks, get_query_heatmap and get_query_store_regressions now say where their data starts, with the contract Lite's twins use (#5015): effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back on a data answer, and the same three keys under hints on an empty answer, so an empty or short answer over a window the store never covered no longer reads as a quiet one. not_collected and unavailable answers keep their shape. get_waiting_tasks, the heatmap and the regressions tool always carry the keys, as Darling's Queries tools do; get_active_queries carries them only when the window was cut, by the house rule that a payload omits null and false fields.

A shared helper, DarlingMcpWindowNotice, probes coverage through DataWindowFloor.GetAsync (the registry's created_date and the purge edge, a run counting as well as a row), with Lite's sentences copied and pinned. A window of 90 minutes or less with rows starts no probe; an empty answer always probes; the regressions tool probes from its baseline start. A probe that fails costs the notice, never the rows: it is logged and the answer goes without the keys. The web keeps its own note for the reads it lists (WebDataStartNote strips the tool's keys before deciding); the Active Queries, Query Heatmap and Query Store Regressions panels now show the tool's own sentence, as the Top Queries panels already do.

In both apps, get_active_queries now previews query text at 400 characters instead of 500, so a cut window (keys plus note) stays inside the 32,768-byte response budget: the default call over the budget test's 50 heavy rows is 30,223 bytes covered and 30,504 cut. get_active_queries' and get_waiting_tasks' descriptions now name the three keys in both apps.

Tests: helper tests (the 90-minute rule, the null floor, the exact sentences, a probe that throws, a caller who cancels); live tests for each tool (a notice when coverage starts inside the window, none on a quiet start, hints on an empty answer, a failed probe that keeps the rows); a covered and a cut budget case; the web guard; the census rosters and the shared-formatter pin. The full Darling suite fails only tests on the Mac-only list; Lite's description, budget and guide classes pass on Windows.

Lite's get_server_config_changes, get_database_config_changes, get_trace_flag_changes, get_collection_log (one server), get_plan_corrections and get_memory_pressure_events now say where their data starts, with the contract the other Lite window-floor tools use (#5004, #5015, #5127): effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back on a data answer, the same keys under hints on an empty answer, and not_collected and unavailable answers unchanged. The fleet form of get_collection_log names no server, so it carries no notice.

Each probe reads the source its tool returns rows from: the config snapshots on capture_time with their collector's runs, the collection log over the read's own window, v_plan_correction, and memory pressure on sample_time. None needs the event-time rule, because each probe already reads the column the rows are windowed on. The config change lists diff each in-window snapshot against the one before it, so a snapshot from before the window also counts as coverage, but only when the server was monitored during the window (at least one collection-log run); with no run in the window the answer says nothing was read (LocalDataService.GetConfigSnapshotCoverageFloorAsync). A plan-corrections answer that holds only automatic-tuning rows is probed as a data answer. The one-server collection log now treats a status filter as a filter, so "no ERROR runs" takes the filtered-empty answer rather than the quiet-window one.

Tests: McpWindowNoticeConfigAndLogToolTests (new): for each tool, a notice when collection starts inside the window, none on a quiet start, hints on an empty answer past coverage, the key order, a short window that starts no probe, a capped page where the tool pages; the config tools' pre-window snapshot with and without a run in the window; a status filter that matches nothing; a one-hour automatic-tuning-only answer. The census rosters and the shared-formatter pin cover the four files. On Windows, the three Lite window-notice classes pass (147).

Part of #4966

* Web grids: column groups with a toggle strip, first used on Memory Conditions and Node OOM (#5152)

Part of #4843

## What users see change
- The System Events **Memory Conditions** (32 columns) and **Memory Node OOM** (28 columns) tables now open narrow. A "Columns:" strip above the table has one toggle per column group, and a pressed toggle shows its columns.
  - Memory Conditions: Event Time, Last Notification, Report and the OOM counters are always shown. Groups: Process memory (on at first), Pressure flags, Allocation detail.
  - Node OOM: Event Time and the node ids and utilisation are always shown. Groups: Failure (on at first), Memory sizes, Pool detail, Flags.
- The choice survives the 60 s repaint. Another server's table starts from the defaults.
- Every other grid is unchanged.

## What changed
- `panels.js`: `vizTable` takes `groups` and `defaultGroups`. A column carries `group: "<name>"`; a column with no group is always shown. The toggle state sits in a module-scope map, keyed like the grid sort state (route, table identity, columns). A hidden column is still built and only set to `display: none`, so the sort, Copy and CSV column indexes stay aligned.
- `server-tabs.js`: `table()` takes an optional last argument `columnGroups = { groups, defaultGroups }`. The two wide column constants are grouped in the desktop grid's order, and #5118's "picker candidate" comments are gone.
- `css/app.css`: `.col-picker` rules beside the `.grid-tools` rules.
- No new module, so no harness needed an import change.

## Decisions
- **Copy and CSV export all columns, hidden ones included.** The desktop grid's export (`DataGridExport`) iterates every column of the grid, shown or not, so the web matches it. Copy cell and Copy row follow the same rule.
- **Sort:** hiding a sorted column keeps the sort. The order holds, the repaint re-applies it, and the other columns still sort.

## What's not changed
- No other grid gets groups. The wider Top Queries and Query Store grids are for the following lanes, which can reuse this option.

## Verified at the head
- New `ColumnGroupsBehaviourTests` (node, 6 tests): no groups gives an unchanged table, defaults, toggling, rebuild and server switch, sort with a hidden column, and Copy carrying all columns. RED on dev: with dev's `panels.js` four scenarios crash with no picker and the fifth shows no strip. 6/6 green now.
  - The no-groups case asserts the DOM is dev's: children are exactly `grid-tools`, `table-wrap`; no th or td carries a `display` style; `tools:false` returns the bare `table-wrap`.
  - The sort case uses a fixture where A's order (y, x, z) differs from B's (y, z, x). With the comparator forced to return 0, the check fails (`byA` comes back x, y, z).
- New `ServerTabColumnGroupsPinTests` (2): each grid's groups match its group list and defaults, and each constant is the last argument of its own `table()` call (Memory Conditions, Memory Node OOM). 2/2.
- `ServerPageTabsTests` 19/19, `WebModuleLoadTests` 7/7, `GridToolsBehaviourTests` 9/9, `PgIndexBloatCoverageTests` 16/16 (this file is not changed by the PR; its `span, noteKey }` pin still matches because the `table()` literal keeps that text).
- The wider guard family and every `.mjs`-harness class: 239 classes run on this Mac. The one failure, `LivePostgresCollectionHygieneTests`, is in `dev-local-failing-tests.txt`.
- `ServerPageTabsTests` was updated: it now lists `columnGroups = null` in the `table()` signature.
- `scrubcheck` on the diff exits 0.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Column toggles on the wide System Events grids** ([#5152]) - Memory Conditions and Memory Node OOM open with a short default column set and a "Columns:" strip that reveals the rest, and the choice survives the page's refresh.
REF: [#5152]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5152

* The web Alert History page gains Dismiss Selected and Dismiss All (#5151)

Part of #4843

## What users see
On the web Alert History page, a signed-in seat that can edit now gets:
- a checkbox on every live row;
- a **Dismiss Selected** button (it shows the checked count);
- a **Dismiss All** button that dismisses every live row the current filter box shows, after the Darling Viewer's prompt ("Dismiss N alert(s)? … hidden from this view but remain in the store"). When the page is truncated (more alerts than the row limit shows), the prompt adds that only the listed alerts are dismissed and the rest stay live.

After a dismiss the list refreshes. With Show dismissed off the rows disappear. With it on they stay and say "Dismissed". A short status line reports the counts, for example "Dismissed 12 of 12", or "Dismissed 1 of 4; 3 were already gone". A refusal (403, 401, 400, 5xx) shows the server's text in the status line and the page never claims success. A read-only seat sees none of this.

## What changed
- `alerts.js`: the select column, the dismiss bar and the write, which goes through the shared `apiSend` helper (JSON content type, session-expiry handling) to `POST /api/alert-history/dismiss` (#5106). The body is `{alerts:[{alert_time, server_id, metric_name}]}`.
- The checked rows live at module scope, keyed by the row key. They survive the 60 s rebuild and a visit elsewhere and back. After a successful dismiss only the keys that were sent are removed, so a box ticked while the request was in flight stays checked. A failed or empty poll read keeps the set; only a successful read prunes rows that left the list. The set is cleared when the range, row limit, server, Show dismissed or filter box changes. 
- Dismiss All sends the listed rows, with no new route. This differs from the Viewer, whose Dismiss All covers every live alert in the window, even past the row limit; the web keeps its listed-rows design and says so in the prompt when the page is truncated. The route caps a request at 1000 keys, so a longer list goes in chunks of 1000 and the counts are added up. A failure part-way says how far it got and refreshes the list.
- Dismissed rows, and rows with no `server_id`, get no checkbox and are not sent.
- `panels.js`: a column flag `copy: false` leaves a column out of Copy row and Copy all (the CSV already honours `csv: false`). The Select column sets it; the Mute column is unchanged. Copy cell and the picked-cell lookup still see every column.
- `css/app.css`: three rules beside `.filter-box`.
- The #5109 range controls and the #5110 `markDismissed` / `tools: false` row build are untouched.

## What's not changed
No server code, no other page, `util.js` or `server-tabs.js`; in `panels.js` only the `copy` flag. There is no "since" route.

## Tests
- New `AlertHistoryDismissBehaviourTests` (13, with `alert-history-dismiss-harness.mjs`, run under Node on the shipped page): no controls for a read-only seat; Dismiss Selected posts the right keys and refreshes; Show dismissed keeps the row with "Dismissed"; Dismiss All posts what the filter shows; chunking at 1000; the checked set surviving a poll and a re-open and clearing on a filter change; a 403 and a 401 shown and the selection kept; the counts line; the prompt wording and the truncated-page line; Copy row and Copy all without the Select column and with Mute kept; a failed poll read keeping the checked set; a box ticked mid-dismiss surviving unsent.
- `AlertHistoryRangePageTests` dropped its "page has no Dismiss Selected" assertion. `MuteRulesPageTests` pins the new column expression. The range harness's metric column index moved from 2 to 3 because of the select column.

## Verified at the head
- RED on dev: with `alerts.js` reset to dev, 8 of the original 9 new tests fail; the four added for the prompt, Copy and selection handling also fail there. The read-only test passes there because it has nothing to show.
- GREEN: `AlertHistoryDismissBehaviourTests` 13/13, `AlertHistoryRangePageTests` 12/12, `MuteRulesPageTests` 6/6, `WebModuleLoadTests` 7/7. `load-web-modules.mjs` loads all 45 modules.
- Every `.mjs`-harness class and 104 classes in total (those plus the whole Census/Ratchet/Adoption/Discipline/Guard/Scrub/Hygiene/Pin/Convention family): one failure, `LivePostgresCollectionHygieneTests.EveryClassUsingTheSharedStore_IsSerializedOrDocumentsWhyNot`, which is on `dev-local-failing-tests.txt`.
- The guard-family run above covers 238 classes, including `GridToolsBehaviourTests` 9/9 (the shared renderer changed) and `WebModuleLoadTests` 7/7. The earlier full-suite run was before the review fixes; the full suite is left to CI. 0 build warnings.
- `scrubcheck` exits 0 on the diff and on this file.
- Not run: nothing required. Lite.Tests is untouched.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Dismiss Selected and Dismiss All on the web Alert History page** ([#5151]) - a seat that can edit gets row checkboxes and the desktop's two dismiss buttons, with a status line of how many alerts were dismissed.
REF: [#5151]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5151

* Web: cross-server Job History page, plus a Job History grid on the Activity tab (#5150)

Part of #4843

## What users see change
- A new **Job History** entry at the end of the web nav opens a page showing SQL Agent job runs (steps and job outcomes) across the fleet, newest first, the web twin of the desktop Job History view.
- Columns follow the desktop grid: Run Time, Server, Job, Category, Step, Status, Duration, Retries, Last Success, Message. Sort, Copy and CSV come from the shared grid.
- Filters: Server (all or one), Window (1/4/12/24 hours, 7 days; default 24 hours), Job (exact name, applied on Enter or when the box loses focus), Status (all, Failed, Succeeded, Retry, Canceled), Category (the categories seen so far), and Rows (100/250/500/1000, default 100). All filters are applied by the read before its row limit. Choices, typed text and focus survive the 60 s refresh.
- The page says when the window reaches past the history the store keeps (also when the answer is empty), and when the row limit cut the list. A "N runs shown" line sits above the grid.
- Rows are coloured as the desktop grid does: failed red, long-running and retry amber, canceled grey. The desktop also dims disabled jobs; the read does not return an enabled flag on this path, so the web does not.
- Differences from the desktop tab: the windows are 1/4/12/24 hours and 7 days (the desktop also has 30 d, 90 d and 1 y; the read's maximum is 168 hours), and there is no 2,000-row cap, the Rows choice goes to 1000 at most.
- The Server choices list SQL Server targets only (PostgreSQL targets have no Agent history).
- The server page's Activity tab (SQL Server) gains a per-server Job History grid with the same columns less Server (newest 100 runs in the page range). It was not on the web before.

## What changed
- `pages/job-history.js` (new), reading `get_job_history` through `readToolWithinKeptHistory` with the render's `AbortSignal`; server choices from `list_servers`.
- `index.html` and `app.js`: one nav entry, import, route, active-nav key, each added at the end of its list.
- `server-tabs.js`: the Activity-tab grid and its column list only.
- `job-history-harness.mjs` and `JobHistoryPageTests.cs` (15 tests).
- `app.css`: three row-colour rules beside the data-table cell colours. `util.js`: `run_time` added to the instant fields, so a custom range trims the per-server grid. No C# product change.
- The job box defers its blur by a turn, so a rebuild no longer drops focus or fires a second read, and the caret is kept. The empty-answer notice reads `hints`.

## What's not changed
`panels.js`, the rest of `util.js`, `alerts.js`, the `app.js` scheduler and status bar, other `server-tabs.js` regions. The PostgreSQL Activity tab has no Job History (Agent history is SQL Server only). The desktop's Agent status indicator is not on this page (it needs a separate `get_agent_status` read).

## Verified at the head
- `JobHistoryPageTests`: 15/15 pass; all FAIL on dev's wwwroot (page and wiring removed).
- `WebModuleLoadTests` passes; module count comes from disk.
- Census/Ratchet/Adoption/Discipline/Guard/Scrub/Hygiene/Pin/Convention family (79 classes) plus every `.mjs`-harness class (26): 1255 run, 1 failed, `LivePostgresCollectionHygieneTests.EveryClassUsingTheSharedStore_IsSerializedOrDocumentsWhyNot`, which is in `dev-local-failing-tests.txt`.
- Full Darling.Tests (run at the previous head; this push re-ran the page, harness, load and guard classes only): 22703 run, 478 failed (377 distinct methods). All are on `dev-local-failing-tests.txt` except two `DarlingManagedPostgresTests.EnsureConfAppended_*` methods; they do not touch web files and I did not run them on a clean dev, so CI decides.
- `scrubcheck` on the diff and this file: exit 0.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Job History page in the web dashboard** ([#5150]) - A cross-server Job History page shows SQL Agent job runs with server, window, job, status, category and row-limit filters, and the Activity tab gains a per-server Job History grid.
REF: [#5150]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5150

* The web dashboard shows a query's stored plan XML from Top Queries, High Impact and Expensive Queries (#5156)

Part of #4843

## What users see
Rows that carry a `query_hash` get a **Plan** button in Top Queries (server page), FinOps High Impact and FinOps Optimization > Expensive Queries. It opens a panel under the row with the query's stored plan XML, indented, with **Copy** and **Download .sqlplan** (file named `<query_hash>.sqlplan`, type `application/xml`). A plan larger than the read's 500 KB cut says so and turns Download off, because a cut file will not open. A query with no stored plan shows the read's own message. The open panel stays open through the 60 s rebuild.

## What changed
- New `pages/plan-viewer.js`: `planColumn(server)` (added to each grid's column list), `storedPlanCell`, `openStoredPlan`, `prettyPrintXml`, `downloadPlan`. It reuses `copyText` from `grid-tools.js`. The XML is drawn only through `textContent` of a `<pre>`.
- `get_plan_xml` returns bare XML for the MCP clients (unchanged). The web read route wraps a stored plan as 200 JSON `{query_hash, database_name, plan_xml, truncated}`; `truncated` is decided on the server from the tool's own cut marker, which is removed from `plan_xml`. A "no plan" status or error envelope passes through unchanged. The viewer reads `plan_xml` and `truncated`; it no longer sniffs text. Params: `server`, `query_hash`, and `database_name` when the row has one.
- Expensive Queries rows now carry `query_hash` (`MAX(query_hash)` in the existing statement query, a new optional `QueryHash` on the `ExpensiveQuery` record, and a `query_hash` field in the `get_finops` optimization view's expensive-query rows), so the Plan column shows there. No tool description changed.
- Open panels are keyed by server + database + hash at module scope; a rebuilt cell redraws from that state, and a read still in flight fills the new cell too.
- Top Queries (both the tab and the standalone panel), High Impact and Expensive Queries use `[...COLUMNS, planColumn(server)]`; the column hides when no row has a hash. `css/app.css` gets three rules beside the grid-tools rules.
- Every `.mjs` harness that copies `server-tabs.js` or the finops tabs now also copies `plan-viewer.js`.

## Not changed
Get Actual Plan and a plan tree view stay out. The PostgreSQL Top Query Shapes grid has no Plan column: `get_plan_xml` is SQL Server only (pinned). No `panels.js`, `util.js`, `app.js` or `alerts.js` edits.

## Verified at the head
- Web wrapper: 3 new `DarlingWebEndpointsTests` cases (whole plan gives JSON that classifies as passthrough; a cut plan sets `truncated` using the real `McpHelpers.Truncate` marker; envelopes pass through byte-identical). `DarlingWebEndpointsTests` 76/76. The plan harness now fakes the 200 shape. `FinOpsTabOptimizationPageTests` pins the new `query_hash` row field.
- `WebExceptionTextCensusTests` 40 (1 skipped), `DarlingCustomViewsTests` 71/71 (`get_plan_xml` params unchanged; a custom view calling it now gets a `plan_xml` JSON object instead of a 400 error strip), `McpToolsListBudgetTests` 5/5, `FinOpsOptimizationViewTests` 8/8.
- RED on dev: `WebPlanViewerPageTests` 5 of 6 fail, `PlanViewerBehaviourTests` 7 of 7 fail (module absent).
- GREEN: `WebPlanViewerPageTests` 6/6, `PlanViewerBehaviourTests` 7/7 (open, text-only, download name/type/body, copy, truncation disables Download, no-plan text, state survives rebuild).
- Updated import allowlists in `FinOpsTabHighImpactPageTests` (8/8) and `FinOpsTabOptimizationPageTests` (16/16); `RepoFileAdoptionTests` 2/2.
- All 100 `.mjs`-harness and Census/Ratchet/Adoption/Discipline/Guard/Scrub/Hygiene/Pin/Convention classes run; only `LivePostgresCollectionHygieneTests` fails, which is on the Mac-local dev failing list.
- Build 0 errors, no new CA/CS/IDE warnings.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Stored query plans on the web dashboard** ([#5156]) - Top Queries, High Impact and Expensive Queries rows with a query hash get a Plan button that shows the stored plan XML with Copy and Download .sqlplan.
REF: [#5156]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5156

* PostgreSQL plans are grouped by their shape, so estimate drift no longer reads as a plan change (fixes #5114) (#5155)

PostgreSQL plans are now grouped by their shape. The plan hash (PgPlanLogParser.FromBlock's PlanHash) covered the whole redacted plan JSON, cost and row estimates included, so a statistics refresh, a different literal or a different work_mem gave one plan shape a new hash, and the plan-flip finding read estimate drift as a plan change. The hash now covers the shape only: it is taken over a clone of the redacted plan with the estimates, the runtime counters (Actual *, Rows Removed by *, Index Searches, Heap Fetches, sort, hash and memoize counters, Workers and Workers Launched), the buffer, I/O and WAL counters, Workers Planned, and the root's Settings, JIT, Planning, Planning Time, Execution Time and Triggers removed, recursively through every child plan. It is a denylist, so an unknown key still splits the hash. The stored plan_json is unchanged and keeps every estimate. A plan without any of those fields hashes as before.

Captured-plan lists (get_pg_plans and the viewer) fold a shape's estimate variants into one row, so captures and durations sum over the shape. Each row now shows the JSON of that shape's latest collection cycle: the reader ranks and cuts the groups first and fetches the JSON for the page only, instead of the old min(plan_json), which was arbitrary once a group held JSONs with different estimates, and aggregated kilobyte-sized text over every group.

On the first analysis pass after the upgrade, a statement captured on both sides of it may report one plan change, and captured-plan lists and the store log's slow_plan groups show pre-upgrade groups beside new ones until the older rows age out (plan retention is 14 days by default).

Tests: PgPlanShapeHashTests (new, 29 cases): estimate, settings, timing and counter variants give one hash, including on a grandchild and a SubPlan node; a different node type, join order, join method, index or parallel awareness gives another; Workers Planned is not shape; an unknown key still splits; the stored JSON keeps its estimates; the hash stays 32 hex characters. A store-log case (two plans differing only in costs are one row) and a live reader case (one group, two captures, the later capture's JSON).

Fixes #5114

* Record the target session id on SQL Server collector runs (#5132) (#5154)

`collection_log.target_session_id` was null on every SQL Server collector run. Monitored-server connections use Multiple Active Result Sets, and under MARS `SqlConnection.ServerProcessId` is 0 at every point: after open, with a reader open, after it closes, and on pool reuse. Measured on SQL Server 2025 with Microsoft.Data.SqlClient 7.1.1. With MARS off it is the real session id.

**The fix.** Right after a collector's target connection opens, before any reader, the runner resolves the session id:
- When `ServerProcessId` is 0, it runs `SELECT @@SPID` once, with a 5-second command timeout, and caches the answer under the connection's `ClientConnectionId`.
- That id stays the same across pool reuse, so a reused connection costs no query, and the cost is one lookup per new physical connection rather than one per run.
- The cache is bounded at 4,096 entries.
- The lookup is timed into the run's SQL time.
- A failure records null and never fails the run. A real cancellation still propagates.
- If SqlClient transparently replaced the physical connection by the time the first reader opens (a new `ClientConnectionId`), the run records null instead of a stale id.
- PostgreSQL targets still read the backend pid directly.

MARS, the pools and the application name are unchanged.

**Why not the alternatives.** Turning MARS off would break the places that run a second command while a reader is open. A per-collector application name would multiply the connection pools on every monitored server.

**Tests.**
- Unit facts for the cache: one lookup per connection id, the bound, failures not cached, a real cancellation, the key read after the lookup, and eviction of a replaced connection.
- A source pin: the lookup comes before every reader.
- Live facts against a real SQL Server: the true session id; no second lookup on a pooled reopen; a full collector run writing a non-null `target_session_id` into its own scratch store.

CI runs no SQL Server, so it skips the live facts. They were run locally.

Fixes #5132

* Web Blocking tab: desktop columns on the Blocking grid, and Save XML on Blocked Process Reports and Deadlock Graphs (#5164)

Part of #4843

## What users see
On a server page's Blocking tab:
- **Blocking grid** now shows the desktop Blocked Process Reports grid's columns, in its order and under its headers (Event Time, Blocked SQL, Blocking SQL, Source, Database, Blocked SPID, Blocking SPID, Wait Time, Wait Resource, Lock Mode, then status, isolation, transaction, priority, log used, login, host and app for each side). It has 25 columns, so the status/isolation, transaction and sessions columns sit behind the column-group toggles from #5152 (status/isolation and transaction on at first).
- **Blocked Process Reports** panel: desktop headers (Event Time, Blocked SPID, Blocking SPID, Wait Time) and a **Save XML** button per row, downloading `blocked_process_<yyyyMMdd_HHmmss>.xml`.
- **Deadlock Graphs** panel: a **Save XML** button per row, downloading `deadlock_<yyyyMMdd_HHmmss>.xdl` (the item #5139 deferred until a download helper existed).
- A deadlock graph the read cut short (`deadlock_graph_xml_truncated`) gets a disabled button with a hover reason. The tab reads with `full_graph` on, so this is the rare case. A row with no XML also gets a disabled button.

## What changed
- `grid-tools.js`: new `downloadText(fileName, parts, type)`; `downloadCsv` now calls it (same Blob, same type).
- `server-tabs.js` (Blocking/Deadlock regions only): `BLOCKING_COLUMNS` (11 -> 25) with `BLOCKING_GROUPS`; `BPR_COLUMNS` reordered with the desktop headers and an XML column; `deadlockXmlColumns` gets the XML column; `saveXmlButton` and `fileStamp` helpers; one import.
- No fetch on click: both reads already return the full XML on the row, so nothing is in flight. There is therefore no in-flight state.
- No harness `.mjs` import list changes: `grid-tools.js` is already in every harness's conditional copy list.
- Tests: `WebBlockingColumnsPageTests` (column keys against `get_blocking` / `get_blocked_process_xml` projections, desktop order and headers, group names, file names); `WebBlockingSaveXmlBehaviourTests` + `web-blocking-save-xml-harness.mjs` (Node: Blob name, type and text for both downloads, truncated disables Save, no-XML disables Save, no extra read).

## Not changed / read-widening follow-ups
- The desktop's Blocked Process Reports grid has no column the read lacks. Not on the web grid: the blocking-side transaction/priority fields, which the desktop does not show either.
- `get_blocked_process_xml` returns only 6 fields, so the Blocked Process Reports panel stays at the desktop's four data columns; the desktop's extra columns live on the Blocking grid (from `get_blocking`).
- **`.xdl`, not `.xml`:** the Viewer and Lite save deadlock graphs as `.xml`; the web uses `.xdl`, which SSMS opens as a graph (the deprecated Dashboard did the same). A follow-up can move the desktop to `.xdl` too.
- Reads not widened.

## Verified at the head
- New page tests: 6/6 pass; node behaviour tests: 4/4 pass. On dev's JS (`git checkout` of the two files, rebuilt tests kept): page 4/6 fail, behaviour 4/4 fail (RED).
- Full Darling.Tests on this Mac: Total 22912, Failed 478 (336 distinct methods); 334 are in `dev-local-failing-tests.txt`. The other two, `DarlingManagedPostgresTests.EnsureConfAppended_*` (2 methods), fail with `kernel32.dll` not loadable on macOS (a platform failure, nothing to do with this change).
- That run included `WebModuleLoadTests`, `DeadlockProcessRowsTests`, every `.mjs`-harness class and the Census/Guard/Hygiene family, which showed no failures beyond the above.
- Build: 0 warnings, 0 errors.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Web Blocking tab shows the desktop's blocking columns and saves report and deadlock XML** ([#5164]) - the Blocking grid has the desktop Blocked Process Reports columns (the wide ones behind column-group toggles), and the Blocked Process Reports and Deadlock Graphs rows have a Save XML button that downloads the XML (`.xml`) or the deadlock graph (`.xdl`).
REF: [#5164]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5164

* Web Perfmon: multi-select counter picker over a multi-series trend chart (#5162)

Part of #4843

## What users see change
The Perfmon chart on a server's page used to chart one counter chosen from a drop-down. It now matches the desktop viewer's picker and the Wait Stats picker from #5119:
- a checkbox list of the counters in the snapshot table above it, with a search box, Select All, Clear All and a Default counters button;
- the default set is the desktop's General Throughput pack (Batch Requests/sec, SQL Compilations/sec, SQL Re-Compilations/sec, Query optimizations/sec, Network IO waits), limited to what this server collects; if none of the pack is collected the first counter is checked;
- up to 10 counters at once (the 11th box is disabled with a "Limit reached" hint; the desktop's cap is 12);
- one line per checked counter on one zoomable chart. Rate counters plot their per-second figure on a per-second axis; a chart that mixes in a gauge or average counter plots plain values with no unit;
- a counter whose read fails gets a note above the chart and the other lines still draw; all reads failing shows an error plus the notes; all reads empty shows the empty state, not an error;
- checks and search text survive the 60 s refresh, per server; a checked counter that leaves the snapshot stays checked and listed; the search box keeps focus and caret.

## What changed
- `server-tabs.js`, Perfmon region only: `perfmonPanel` and `drawPerfmonTrends` (replaces `drawPerfmonTrend`; `perfmonTrendLines` is gone). One `get_perfmon_trend` read per checked counter, in parallel, with an AbortController per server for superseded draws, and the chart draws at most 10 counters whatever it is handed.
- `multi-picker.js`: three optional options (`noun`, `defaultsLabel`, `defaults`) so the wording and default set are not wait-specific. Defaults are the old values, so Wait Stats is unchanged.
- `BaselineDiscontinuityRenderCensusTests` (Lite) now looks for `export async function drawPerfmonTrends(`. Its logic still holds: that function keeps one `discontinuityNotes(trend.data)` use, and the file total stays 4. Read by hand; Lite.Tests was not run on this Mac.
- New `WebPerfmonMultiSelectBehaviourTests` (14) with `web-perfmon-multi-harness.mjs`; `WebPerfmonPerSecondBehaviourTests` expectations changed from "Per second"/"Value"/"Delta" series to one line per counter.

## What is not changed
- The snapshot table, its per-second columns, and `get_perfmon_trend`. The read takes a counter name and no instance, so the list is by counter name (the desktop also lists by counter name). The desktop's other packs are not offered.
- `panels.js`, `util.js`, `charts.js`, `app.js`, CSS (the existing picker rules are enough).

## Also
- The trend harness copies `pages/plan-viewer.js` when it exists, so it keeps loading once the plan viewer import lands in `server-tabs.js`.
- When none of the default pack is collected, the first counter is checked (the desktop checks none); this continues the page's earlier behaviour.

## Verified at the head
- `WebPerfmonMultiSelectBehaviourTests`: 15 pass (one new: a counter with no trend lists the collected counters as text, "Collected here: …", instead of pointing at a JSON path). `WebPerfmonPerSecondBehaviourTests`: 10 pass. `WebWaitStatsMultiSelectBehaviourTests`: 14 pass, unchanged.
- RED on dev: the new harness cannot pass against dev's `server-tabs.js` (no picker, no `drawPerfmonTrends`; the `defaults` scenario fails at the missing Select All / mp-count node).
- Every `.mjs` / `load-web-modules` class, `WebModuleLoadTests` and the Census/Ratchet/Adoption/Discipline/Guard/Scrub/Hygiene/Pin/Convention family (104 classes, 1,300 tests): 1 failure, `LivePostgresCollectionHygieneTests` (Mac-local; it cannot load WPF types here).
- `scrubcheck` on the diff: exit 0.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Web Perfmon chart charts several counters at once** ([#5162]) - the chart now has the desktop's checkbox list with search, Select All, Clear All and the General Throughput default set, up to 10 counters on one zoomable chart. Your choices are kept across the page's refresh.
REF: [#5162]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5162

* WEB: the PostgreSQL tabs' window aggregates and trend grids say where their data starts (part of #4966) (#5165)

The web server page's PostgreSQL tabs now say where their data starts on eleven more reads: top queries, blocking, database stats, session states, I/O stats and replication stats, the database, query-duration, wait and I/O trend grids, and captured plans. WebDataStartNote lists each read with the table its rows come from; blocking, session states and replication stats are sparse (most samples store nothing) and have no retention edge, so they are probed on their own collector's runs instead (CollectorRunsByRead), and a long-monitored server whose first blocking chain or replica came recently reads as covered.

Each read's "captured, found nothing" status word is admitted where it has one (no_blocking_sampled, no_io_activity, empty for the database, session-state and trend reads), never not_sampled, unavailable or precondition; tools that answer rows with a status word and no message (session_states, blocking_sampled, cycles_only, the trend words and others) are accepted as row answers. No stat tile beside these reads shows only newest values, so no panel opts out, and server-tabs.js is unchanged.

Tests: the live PostgreSQL theories cover the 17 noted tables; a sparse read on an old server with one late row gets no note, and on a two-day-old server gets one; the status words are pinned from the tool sources and by behaviour; 11 page scenarios through the Node harness; the source pin is scoped to the listed reads.

Part of #4966

* Web line charts get a menu: copy/save image, export CSV, show data source, reset zoom (#5166)

Part of #4843

## What users see
Every web line chart has a small "⋯" button at its top right, and right-click on the chart opens the same menu. Items, in the Viewer's order and wording:
- **Copy Image**: draws the chart to a PNG and puts it on the clipboard (secure page only; otherwise a plain message says why).
- **Save Image As...**: downloads the PNG.
- **Reset zoom**: only while the chart is zoomed (#5092).
- **Export Data to CSV...**: one row per time and series (`DateTime (UTC),Series,Value`). It exports every loaded point, also while the chart is zoomed, the same as the Viewer. Times are UTC in three columns; the Viewer writes the display time zone plus a UTC-offset column.
- **Show Data Source**: the read name and its parameters, shown under the chart. Hidden when the chart has no read to name.

The menu is keyboard reachable: Tab to the button, Enter to open, Arrow keys to move, Escape or Tab to close. A right-click menu opens at the pointer and stays inside the chart. The open menu and the Show Data Source panel stay open across the 60 s refresh (kept at module scope per chart and page scope, for 90 s).

## What changed
- `charts.js`: `renderLineChart` attaches the menu, so every chart gets it. New optional spec fields `title`, `source` ({ read, params }), `zoomed`, `onResetZoom`. `zoomableLineChart` supplies the zoom pair. No new exports are imported by any other module.
- `panels.js`: the panel chart passes its title and its read name and params (two lines).
- `css/app.css`: menu rules beside the chart tooltip rules.
- CSV reuses `grid-tools.js` (`toCsv`, `csvFileName`, `downloadCsv`), loaded on first use. PNG: the SVG is cloned with computed styles written inline, drawn on a canvas, no external fetches.
- Copy Image hands the clipboard a promise for the PNG (Safari needs the write started inside the click); a failed CSV module load shows "Export failed".
- Tests: `WebChartMenuBehaviourTests` + `web-chart-menu-harness.mjs`.

## Not changed
- **Open in New Window** is out of scope.
- Server-tab and FinOps charts that call `zoomableLineChart` directly pass no `source`, so they show no Show Data Source until a caller passes it.
- Bar, pie and scatter charts have no menu.

## Verified at the head
- New harness on dev's `charts.js`: RED (the menu does not exist; the harness throws). On this head: 8/8 pass (adds right-click placement `right:auto` + clamped left/top, zoomed CSV = all 11 points, Tab closes, menu and source survive a rebuild).
- Every `.mjs`-harness class (incl. `WebModuleLoadTests` 7/7, `WebChartZoomBehaviourTests` 10/10, `GridToolsBehaviourTests` 9/9): 0 failures. `ChartIntegerTicksPinTests` needed the spec destructure kept as is; passes 3/3.
- This run: the targeted classes, every `.mjs` class and the guard family, 113 classes, 1364 tests, 1 failure.
- Guard family (all classes ending Census/Ratchet/Adoption/Discipline/Guard/Scrub/Hygiene/Pin/Convention Tests, plus `DocCommentHygieneTests`): only `LivePostgresCollectionHygieneTests` fails, and it is on `dev-local-failing-tests.txt`.
- Full Darling.Tests on this Mac: 22847 total, 478 failed, 1920 skipped. Every failing class is on the Mac-local list (parameterized rows and `ViewerChartContextMenuTests`, WPF); none touch charts.js.
- Not run in a real browser: the canvas, clipboard and menu placement are untested there (the node harness has no canvas or layout); worth one manual click-through.
- scrubcheck on this file and the diff: exit 0.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Web line charts have a menu** ([#5166]) - Each line chart has a "⋯" button (and right-click) with Copy Image, Save Image As, Export Data to CSV, Show Data Source (where the chart knows its read) and Reset zoom (while zoomed).
REF: [#5166]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5166

* get_server_trend: latch, spinlock, session stats and collector duration trends (#5170)

Part of #4843

## What users see change
`get_server_trend` (and its `/api/read/get_server_trend` route) takes four more `metric` values:

| metric | a point holds |
|---|---|
| `latch` | `wait_time_ms_per_second`, one series per latch class |
| `spinlock` | `collisions_per_second`, one series per spinlock |
| `session_stats` | the server-wide session counts (`total_sessions`, `running_sessions`, `sleeping_sessions`, `background_sessions`, `dormant_sessions`, `idle_sessions_over_30min`, `sessions_waiting_for_memory`, `databases_with_connections`), averaged per bucket, plus the top application and host of the newest collection in the bucket |
| `collector_duration` | `max_duration_ms`, `avg_duration_ms`, `run_count` of successful runs, one series per collector |

- **New `names` parameter** for `latch`, `spinlock` and `collector_duration`: comma-separated, exact case, up to 10, default the top 5 (most wait time, most collisions, longest run). Names with no samples come back in `missing_names`. If none of the named ones has a sample, the answer is `empty` with a distinct note, `missing_names` and `top_names`. `names` on any other metric is refused, and `clerk_types` on these three is refused.
- Latch and spinlock rates use the viewer's rule: summed delta over the summed stored interval, and a restart's unknowable (interval 0) sample is left out rather than drawn as 0.
- Same envelope as before: house window and bucket parameters, omitted nulls, `discontinuities[]`, and `empty` / `unavailable` / `not_collected`. `session_stats` is sized as four single-field points so its default 168 h answer stays under 32 KB.

## What changed
- `ServerTrendSql.cs`: `SessionSummary` (moved verbatim from the viewer), `LatchWaits(n)`, `SpinlockCollisions(n)`, `CollectorDurations(n)`, the three top-N reads and four "ever collected" probes. The viewer's `SessionStatsSql` now points at `ServerTrendSql.SessionSummary`; I diffed the old and new text and they are identical (71 lines).
- Latch, spinlock and collector duration: the viewer's reads have a different shape (per-row rates for a fixed top 5; per-collector buckets with no name filter), so the bucketed, name-filtered reads are written new in the house style. The viewer's SQL is untouched. One new read per metric, no new Storage reader class.
- `DarlingMcpServerTrendTools.cs`: the four metrics, `names`, a shared series path for clerks, latch, spinlock and collectors, notes and `Fields` per metric. `DarlingWebEndpoints.cs`: `names` in the catalog row and dispatch. `GetServerTrend`'s internal overload takes `names` before the budget so the cancellation token stays last (CA1068).
- Budget txt, ceiling, and tests.

## What's not changed
No `wwwroot`, no `DarlingManagedRoles.cs`, no migration, no change to the four earlier metrics' output. Left for a later change: `tempdb_file_io`, `tempdb_size`, `file_io_throughput`.

## Budget
- `tools/list`: 186,010 -> 186,158 bytes (+148); `TotalCeilingBytes` set to 186,158.
- Per-file txt: tool head 279 -> 331 (text grew after the guide marker mostly), `metric` param 61 -> 44 (the list moved into the tool description), new `names` param 70.
- Largest default call at 168 h, on a week of one-minute collections (8 latch classes, 8 spinlocks, 12 collectors): latch 11,127 B, spinlock 10,831 B, session_stats 17,944 B, collector_duration 15,627 B. All under the 32 KB target. The four earlier metrics are unchanged (largest 17,555 B, cpu_scheduler). Widest explicit width: latch 106,172 B at 34 min, spinlock 103,197 B at 34 min, session_stats 149,898 B at 27 min, collector_duration 149,991 B at 34 min, all under the 320 KB cap.

## Verified at the head
- Build: Darling.Tests Release, 0 errors, no CA/CS/IDE warnings after the CA1068 fix.
- `DarlingMcpServerTrendToolsTests`: 13/13 (parameters including `names`, eight-metric list in the description, refusals, every field a note names is emitted, `missing_names` in the description, name/width binding in the SQL, viewer and tool share the session SQL text).
- `DarlingMcpServerTrendToolsLiveTests`: 2/2 on a throwaway TimescaleDB 2.30.1 / PG18 container (host port 55615, removed). The new arm covers planted values for all four metrics (100 ms/s latch, 20/s spinlock, 100 then 120 sessions with the newest collection's application, 9000 then 9100 ms slowest collector), the restart-marker sample dropped as a point, a failed run not counted, `missing_names`, the none-found empty note, `unavailable` before any collection, `empty` for a quiet window, and the 168 h sizes above. Run 4 times; one run failed once on `bucket_minutes` auto sizing (fixed by pinning 1 minute in the planted arms) and is stable since.
- `WebReadCancellationPinTests` 139/139 (its `metric=total_waits` query string still works; the new parameter is optional).
- `McpToolsListBudgetTests` 5/5, `McpToolGuideTests` 10/10, `McpToolGuideHeadsDataTests` 3/3, `BaselineDiscontinuityRenderTests` 3/3, `EngineCapabilityReadWiringTests` 6/6 (the capability question names `latch_stats`, `spinlock_stats` and `session_stats`; `collector_duration` asks no collector because the log is not a gated collector).
- Guard family: all 79 Darling.Tests classes ending in Census, Ratchet, Adoption, Discipline, Guard, Scrub, Hygiene, Pin or Convention + Tests, plus DocCommentHygiene, RepoFileAdoption, TsqlConventionGuard, FleetIdentifierScrub, QueryStoreSliceTieBreakSource, the three command-timeout classes and the viewer session/duration/latch classes: all pass except `LivePostgresCollectionHygieneTests.EveryClassUsingTheSharedStore_IsSerializedOrDocumentsWhyNot` (loads WPF types; on the Mac-local list) and `ViewerCollectorDurationChartTests.TheChart_IsFedItsOwnRead_NotTheGridsPage` (PresentationFramework; on the list).
- Full Darling.Tests once: 22,847 run, 552 failed, 247 skipped. All failing names (430 distinct after dropping theory-row arguments) are on `dev-local-failing-tests.txt`; none is new.
- RED on dev: `Metrics` has four entries, `names` and `SeriesOf`, `ServerTrendSql.SessionSummary`/`LatchWaits`/`SpinlockCollisions`/`CollectorDurations` do not exist, so the new tests fail to compile there (RED by compile failure). Before the sizing fix, session_stats was 68,530 B at 168 h, over the 32 KB target.
- Not run: Lite.Tests (never run on this Mac; the change touches no Lite file).
- Finds: 0 substantial, 1 hygiene fixed in-lane (session_stats default width sizing), 0 polish.
- Follow-up at head 96df107d5 (dev merged in): a latch or spinlock row with a known interval but a NULL delta no longer adds its seconds to the rate (the Viewer drops that row too); `KindMetric` is gone and `ReadTopNamesAsync` always takes the metric. New live test `TheRateRules_SessionNewestCollection_AndCollectorNames_HoldAgainstTheStore` covers the NULL-delta row (RED before the fix: 50 ms/s instead of 100), a latch row with no stored interval (previous-collection gap), two session collections in one bucket (the newest one's attribution) and `names` for collector_duration. `DarlingMcpServerTrendToolsTests` 13/13, live class 3/3 on a throwaway TimescaleDB container (removed); `McpToolsListBudgetTests` 5/5 with tools/list measured at 185,766 bytes, under the unchanged 186,158 ceiling; `McpToolGuideTests`, `WebReadCancellationPinTests` and the 80-class guard family green except `LivePostgresCollectionHygieneTests` (loads PresentationCore, Mac-only).

## CHANGELOG entry
SECTION: Added
ENTRY: - **More server trends over MCP: latch, spinlock, session stats and collector duration** ([#5170]) - `get_server_trend` now also returns the latch wait and spinlock collision rates per name, the server-wide session counts, and the per-collector run duration trend the desktop charts draw, with a `names` filter for the per-name ones.
REF: [#5170]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5170

* Web Top Queries: the desktop grid's 45 columns, grouped, with a detail=full read for the fields the page lacked (#5169)

Part of #4843

## What users see
The server page's Top Queries grid (both the CPU tab panel and the composite panel) now has the desktop Top Queries columns in the desktop's order, 45 in all (the desktop's Query Plan download column is the separate plan work). The core set shows by default: Database, Query, Module, Execs, Total/Avg CPU, Total/Avg Duration, Total/Avg Reads, Total Spills, Query Hash. Six toggles under the "Columns:" strip turn on the rest: Times (Last Execution, Creation Time, Peak CPU ms/s, Plan Gen, Total CLR), I/O and rows, Extremes, Memory grants, Parallelism, Hashes and handles. Sort, Copy and CSV work on the wider grid; Copy and CSV carry every column. On the hourly tier the fields that tier lacks print a dash.

Query sits right of Database, not behind the two timestamps as on the desktop, because a pinned rule keeps query text next to the anchor. Module at index 2 is a choice of this grid, not a rule the pin states. Last Execution and Creation Time print in the browser's local time.

## What changed
- **Page:** `TOP_QUERY_COLUMNS` (45 keys) plus `TOP_QUERY_GROUPS`, passed to both Top Queries grids. Both ask for `detail: "full"`.
- **Read:** `get_top_queries_by_cpu` gains an optional last parameter `detail` (`summary` default, `full`). `full` adds last_execution_time and creation_time (UTC instants written with the "o" format; the read converts them from the monitored server's clock per row, as every other server-local column on the MCP surface is, and the frame census carries a row for them),  min/max physical reads, rows, grant / used grant / ideal grant KB, spills, reserved and used threads, total_clr_ms, plan_generation_num and worker_time_per_second. Fields the store holds no value for are omitted, not written as null. The raw tier only: an hourly-tier read adds nothing, and `precision_note` now says so when `detail=full` was asked and the hourly tier answered. Both aggregates (`TopQueriesSql`, `TopQueriesByHostObjectSql`) select the extra columns and the reader maps them into a `TopQueryDetail` record. The HTTP catalog and dispatch pass `detail`.
- **Size, measured live on a throwaway TimescaleDB, 168 h, top 20:** default call 12,980 bytes, `detail=full` 14,027 bytes (22 groups, only one with the detail columns, short text). A row with every detail field adds 294 bytes, so 20 full rows add about 5.9 KB; the default call is unchanged, and full stays under the 32 KB target unless query text is near its 2,000-char cap on most rows. Neither form has a byte bound: the summary call has no page cut or `truncated` flag for size (query_text is capped at 2,000 characters per row and nothing else bounds the payload), so `full` follows the same rule and adds about 6 KB at 20 rows. A size bound for both forms (a shorter text preview) is a separate follow-up that would change the summary call; it is not part of this PR. Because the default call is untouched, no opt-in was needed beyond `detail`.
- **tools/list:** new line `param get_top_queries_by_cpu.detail 122` in `McpToolsListBudget/DarlingMcpDataTools.txt`. `TotalCeilingBytes` (186,010) did not need to grow (the budget test passes).
- Tests: `DarlingCustomViewsTests` param keys and `WebServerPageRangeTests` read URL now include `detail`.

## Not changed
- `PG_TOP_QUERY_COLUMNS`: a different engine; the PG read does not return these fields.
- Lite and the desktop grid are untouched.
- The Plan column (#5156) is on dev and merged into this branch: both grids pass `[...TOP_QUERY_COLUMNS, planColumn(server)]` plus the groups.
- Peak CPU ms/s is the desktop's `WorkerTimePerSecond`: the max per-collection rate in the window.

## Verified at the head
- New `WebTopQueriesColumnsTests` 8/8: column keys against the read's projection parsed from source, desktop order, group layout (all six groups used, none on by default, core set ungrouped), both grids pass groups and `detail: "full"`, `detail` is an appended optional, both aggregates select the detail columns in the reader's order.
- RED on dev: all 8 fail (the new class run in a worktree of origin/dev).
- New live `TopQueriesDetailLiveTests` 1/1 on a throwaway TimescaleDB container (removed): reader detail values, MCP summary omits them, full carries them and omits nulls, bad `detail` refused, both sizes under 32 KB.
- Also green: `ViewerGridPayloadColumnOrderPinTests` 30/30, `McpToolsListBudgetTests` 5/5, `ServerTabColumnGroupsPinTests` 2/2, `WebServerPageRangeTests` 11/11.
- Full Darling.Tests on the Mac: 22,911 run. After fixing the three failures this PR caused (query-text order, budget txt line, range-test URL), the remaining failures are the Mac-local set (DarlingInstallLocation, Viewer WPF classes, etc.). Five live classes outside the dev-local list failed in the full run and also fail on origin/dev on my rig (TopCpuQueriesTextLive, FinOpsOptimizationTieBreak, StoreMetricsLatestSkipScanLive, ViewerFinOpsIntervalHonestLive, FinOpsOptimizationViewLive); they did not involve this change.
- The guard family and every `.mjs`-harness class were included in that full run.
- At the new head (dev merged in): `WebTopQueriesColumnsTests` 8/8, `TopQueriesDetailLiveTests` 1/1 (non-zero server offset: stored local times come back as UTC; host_object call; a 5-billion physical-reads extreme), `TopQueriesHourlyRoutingLiveTests` 7/7 (a `detail=full` call answered from the hourly tier carries the note and no detail field), `ConsumedTimestampFrameDisciplineTests` 17/17, `ViewerGridPayloadColumnOrderPinTests` 30/30, `McpPayloadContractCensusTests` 68/68, `McpToolsListBudgetTests` 5/5, `WebPlanViewerPageTests` 6/6, the 108-class guard family plus `.mjs`-harness set clean on the Mac except `LivePostgresCollectionHygieneTests` (loads WPF types; dev-local list). Full suite: 22,975 run, 568 failed, every one on the dev-local list or one of the five live classes named above.
- scrubcheck on the diff exits 0.

## CHANGELOG entry
SECTION: Added
ENTRY: - **Web Top Queries shows the desktop grid's columns** ([#5169]) - the server page's Top Queries grid now carries the desktop's 45 columns in six toggle groups (times, I/O and rows, extremes, memory grants, parallelism, hashes and handles), with the core columns on by default. `get_top_queries_by_cpu` gains an optional `detail=full` that adds the grant, thread, extreme, time, CLR and plan-generation fields.
REF: [#5169]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5169

* get_query_store_top answers long windows from the daily summary, and says the answer is approximate (#5094) (#5168)

get_query_store_top answers long windows from the daily summary, and says the answer is approximate (#5094)

Fixes #5094. The table path used to aggregate every interval row in its window on every call. On a large store a 168-hour read took 6-23 s cold and 2.7 s warm, almost all of it heap reads.

- When the interval table serves the read, the whole UTC days inside the window come from collect.query_store_top_daily (built by #5125), and only the partial days at the edges come from collect.query_store_interval_wide. The read takes the longest contiguous run of built days in the gate's own REPEATABLE READ, READ ONLY snapshot, with ties going to the latest run. With no built day in the window, today's statement runs unchanged (same text, same parameters).
- The summary is approximate by design (Erik's call on #5094): a write into a day after it was built is missed, a row that moves into the next day can count twice until the day's second pass, and a monitored-server clock more than an hour ahead drops rows. In the clean case the answer equals today's exactly. Answers that used the summary carry approximate: true, an approximation_note and summary_days, including the two empty answers, and the web Query Store grid shows the note. The viewer's own Query Store read stays exact.
- Read failures keep #5136's accounting: a fault after the gate chose the table, the built-days read included, is recorded as a raw fallback and answers from raw.
- Tests: QueryStoreTopDailyReadLiveTests (25 facts: equality against today's read on fixed past anchors, edge bounds, run selection, the documented double count, the empty-answer flags and the fault fallback), plus the payload-contract census and the web tab pins.

* query_stats can fetch plan XML in a second target query for only the rows that need it (inert, #5158) (#5173)

query_stats can fetch plan XML in a second target query for only the rows that need it (inert, #5158)

Part of #5158, the first of three PRs. Nothing sets the new switch yet, so no collector's SQL or behavior changes.

- With plan capture on, every query_stats run renders sys.dm_exec_text_query_plan for all of its ~200 rows and ships the XML, although almost all of those plans are already stored. #5158 measured 53.75 GB/h rendered and 28.83 GB/h shipped on one large store, against 1.14 GB/h of distinct plans.
- CollectorContext.DeferPlanXmlFetch (default false) leaves the plan fragments out of BuildQuery and skips the plan ordinals in ReadAsync, through one shared predicate.
- QueryStatsCollector.BuildPlanFetchQuery builds one statement for a list of (plan_handle, start offset, end offset) keys: an inline VALUES list of host-rendered 0x literals, OUTER APPLY sys.dm_exec_text_query_plan, the same size-cap CASE and byte column as the inline fragment, and OPTION(RECOMPILE). It throws when capture is off, the list is empty, or a handle is not 1 to 64 bytes. ReadPlanFetchAsync maps each ordinal to its plan and size.
- Row.KnownPlanDigest and ICollectorRowWriter.PayloadOrDigest (default: Value(content)) let a later writer store a known digest in place of the plan text. WritePayload calls it, and with the default every row writes exactly as before.
- Tests: QueryStatsPlanFetchTests (18) and QueryStatsSqlGoldenTests (4 SHA-256 pins of the full BuildQuery text for Standard and Azure SQL DB, capture on and off, computed on dev and unchanged here).

* Web CPU and Memory tabs chart the CPU scheduler, memory clerk and plan cache trends (#5161)

Part of #4843

**Stacked on #5162 (Perfmon multi-select): this branch contains that PR's commits and uses its new `multi-picker.js` options, so it must land after #5162.**

## What users see
- **CPU tab:** a "CPU Scheduler Trend" chart (…
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