Skip to content

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

Merged
erikdarlingdata merged 19 commits into
devfrom
fix/4966-mcp-lite-config-logs
Oct 5, 2026
Merged

erikdarlingdata merged 19 commits into
devfrom
fix/4966-mcp-lite-config-logs

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

This PR also carries the Darling MCP twins (first reviewed as #5131), which edit the same test rosters and so can't land in a separate train. #5131's own description, reviews and test record apply unchanged. In short: Darling's get_active_queries, get_waiting_tasks, get_query_heatmap and get_query_store_regressions now carry the same three data-start keys, through a shared helper, with a failed coverage probe costing the notice and never the rows; get_active_queries writes them only when the window was cut, and its query-text preview is 400 characters in both apps so a cut window stays inside the response budget.

Lite's get_server_config_changes, get_database_config_changes, get_trace_flag_changes, get_collection_log (one-server form), get_plan_corrections and get_memory_pressure_events now say where their data starts. They use the same contract as the other Lite window-floor tools (#5004, #5015, #5127). This is the second Lite group of #4966.

What an MCP client sees

  • A data answer carries effective_start (UTC, with a Z), window_truncated and truncation_note right after hours_back. They are always present: false and null when the store covered the window.
  • An empty answer carries the same three keys under hints. An empty answer is always probed, whatever the window's length. If the store holds no collection of the window, effective_start is null, window_truncated is true, and the note says the empty answer is not a report that nothing happened.
  • A data answer over a window of 90 minutes or less starts no probe and is covered at the asked start.
  • not_collected and unavailable stay bare. So do the engine-capability answers. No tool description changed.
  • No effective_hours_back. get_collection_log and get_plan_corrections carry a page truncated, and the census holds that key apart from the window floor.

Which tools use the event-time rule

None of the six do. EventWindowNoticeAsync is for a list where a first run can store rows older than itself AND the probe reads a different column from the row time. Neither holds here:

Tool Relation and probe column Why the plain notice
the three config tools ServerConfig, DatabaseConfig, TraceFlags, on capture_time A change is stamped with the capture time of the snapshot that showed it. The probe reads that column.
get_collection_log CollectionLog, on collection_time The rows are the run log. A run is stamped with its own time.
get_plan_corrections PlanCorrection, on collection_time Rows are per-run re-captures stamped with the run's time. The read and the probe use the same column.
get_memory_pressure_events MemoryPressureEvents, on sample_time The list is windowed on sample_time, the ring-buffer event's own time. The probe reads that column beside the collector's runs, so a backfilled event already lowers the floor. One test pins this.

Each probe reads the same source its read returns rows from.

The config tools needed one extra step. Their read keeps the snapshot before the window as the diff baseline, and the collectors capture on connect. So a server that stayed connected holds its last snapshot days before the window and none inside it. The plain probe finds nothing in the window and would tell an empty answer "the store holds no collection of this". That would be false. LocalDataService.GetConfigSnapshotCoverageFloorAsync also counts a snapshot before the window's start and answers the start. Without a snapshot before the window it answers what GetQueryWindowFloorAsync answers.

The collection log fleet form

server_name omitted or "*" carries no notice. It merges every enabled server's runs and names no server, so there is no single start to give. A code comment on GetCollectionLogFleetAsync and on the one-server branch says so, and a test pins that the fleet answer has none of the three keys.

For the one-server form the probe reads the run log itself and ignores the collector_name, min_duration_ms and status filters. The note says where the log starts, not where one collector's or one status's runs do. A filter that matches nothing still carries the log's start under hints.

Test plan

  • Mac, Lite.Tests: --no-incremental build, 0 warnings, 0 errors.
  • Mac, Darling classes: EffectiveStartUtcTests and McpPayloadContractCensusTests: Total 78, Failed 0.
    • Both census rosters (window_truncated, truncation_note) gain the four touched files.
    • A new EffectiveStartUtcTests pin covers them: each effective_start write comes from notice.EffectiveStart, the AsHints counts match, and none uses the event-time form.
  • Windows VM, one run: McpWindowNoticeConfigAndLogToolTests (new), McpWindowNoticeEventToolTests and McpWindowNoticeToolTests: Total 142, Failed 0. No rerun was needed.
  • New class, per tool:
    • a notice when collection starts inside the window;
    • none on a quiet start;
    • key order right after hours_back;
    • a short window starts no probe;
    • a limit: 1 capped page, for the two tools that page;
    • empty past coverage carries hints; empty with nothing held says nothing was read; empty with the collector covered says covered (the three config tools have their own cases).
    • Extra cases: a config window with only a pre-window snapshot, the memory pressure backfill, a plan-correction answer holding only the automatic-tuning snapshot, a collection log filter that matches nothing, a never-collected server (unavailable, bare), and the fleet form.
    • Every anchor uses a fixed as_of.
  • RED: by construction, not run. Removing the key writes fails the key-order, notice and hints families. Removing the pre-window snapshot arm fails the config-window case.
  • Full suite: CI runs it at ready time.

Review fixes, at 5936e803a:

  • A config snapshot from before the window counts as coverage only when the server was monitored during the window (at least one collection-log run). With no run, the answer says nothing was read (AConfigWindow_OneSnapshotBeforeIt_ButNoRunInIt_SaysNothingWasRead, for all three config tools).
  • The one-server collection log treats a status filter as a filter (CollectionLog_AStatusFilterThatMatchesNothing_TakesTheFilteredEmptyBranch).
  • A one-hour automatic-tuning-only plan-corrections answer names the probe's floor (PlanCorrections_AOneHourTuningOnlyAnswer_NamesTheProbesFloor_NotTheAskedStart).
  • On Windows, the three Lite window-notice classes: 147 tests, 0 failed. On the Mac, the census and formatter pins: 78, 0 failed.

CHANGELOG

SECTION: Fixed
ENTRY: - Lite's config change, collection log, plan correction and memory pressure MCP tools say where their data starts ([#5147]) - 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 carry effective_start, window_truncated and truncation_note, and an empty answer carries them under hints, so an empty answer over a window the store does not reach back to no longer reads as "nothing happened" (part of #4966).
ENTRY: - Darling's active queries, waiting tasks, query heatmap and Query Store regressions say where their data starts ([#5147]) - These four MCP tools now write effective_start, window_truncated and truncation_note, and carry them under hints on an empty answer, so an empty or short answer no longer reads as a quiet server when the store does not reach back to the start of the window. In both apps, get_active_queries now previews query text at 400 characters instead of 500, to keep a cut window inside the response budget; full_text: true still returns the whole text.
REF: [#5147]: #5147

Part of #4966

…eries and get_waiting_tasks (part of #4966)

Adds DarlingMcpWindowNotice, the Darling twin of Lite's WindowNotice, and the tests that hold the
two session tools, the web guard and the census rosters to the contract. The tests fail until the
tool and web changes in the next commit.
… data starts (part of #4966)

Both tools write effective_start, window_truncated and truncation_note after hours_back on a data
answer, and carry the same three keys under hints on an empty answer. not_collected and unavailable
answers stay bare. The web's data-start note strips the tool's own three keys before it decides, so
the page keeps the note it had, capped or coverage, byte for byte.
…wins

# Conflicts:
#	Darling/Darling.Tests/McpPayloadContractCensusTests.cs
…data starts (part of #4966)

Live pins for both tools (rows that start inside the range, a quiet start, the empty answers' hints, a covered baseline), the census and formatter rosters, and the web page's handling of the tools' own note.
…ere their data starts (part of #4966)

Both tools write effective_start, window_truncated and truncation_note after hours_back on a data answer, and carry the same three keys under hints on every empty answer. Unavailable answers stay bare. The regressions tool checks the start of its baseline, the earlier of its two windows, and its note says the baseline holds only the part from effective_start to baseline_end.
#4966)

A covered default call goes back to its old size, inside the response budget; a cut window still carries effective_start, window_truncated and truncation_note.
…udget (#4966)

A coverage probe that throws (anything but the caller's own cancellation) is now logged at Warning and costs only the notice: a data answer goes out without effective_start / window_truncated / truncation_note, and an empty answer without hints, the rule the web's data-start note already follows. All four tools take an optional logger, which the web dispatch supplies.

The cut-window default call of get_active_queries measured 33,004 bytes over the same 50 heavy rows, 236 over the response budget, so its query_text preview drops from 500 to 400 characters in both apps (30,504 cut, 30,223 covered).

Also: one description sentence in both apps for get_active_queries and get_waiting_tasks (after the guide marker), the census note that Darling's get_active_queries omits the keys when the window is covered, a corrected WebDataStartNote comment, and the session-tool live seeds clear any fleet schedule override for the two collectors.
… budget numbers, and cover a failed probe on the heatmap and regressions tools (#4966)
…wins

# Conflicts:
#	Darling/Darling.Tests/EffectiveStartUtcTests.cs
#	Darling/Darling.Tests/McpPayloadContractCensusTests.cs
… pressure tools say where their data starts (part of #4966)

Lite's get_server_config_changes, get_database_config_changes, get_trace_flag_changes, get_collection_log (the one-server form), get_plan_corrections and get_memory_pressure_events now say where their data starts, with the contract the other Lite window-floor tools use (#4966, #5004, #5015). A data answer carries effective_start (UTC, with the Z), window_truncated and truncation_note right after hours_back; an empty answer carries the same three keys under hints. not_collected, unavailable and the fleet form of get_collection_log keep their shapes.

None of the six needs the event-time form: a config change is stamped with the capture time of the snapshot that showed it, plan-correction rows and log runs with the run's own time, and the memory pressure probe reads sample_time, the column its list is windowed on. The config tools read the snapshot before the window as the diff baseline, so their probe (LocalDataService.GetConfigSnapshotCoverageFloorAsync) also counts a snapshot at or before the window's start; without it a server that stayed connected would be told the store held nothing of a window it covered.

Tests: McpWindowNoticeConfigAndLogToolTests (new). The census rosters and the shared-formatter pin cover the four tool files.

Part of #4966
…atus counts as a filter

A snapshot before the window now only counts as coverage when the server logged at least one run inside the window; otherwise the answer stays "nothing was read". The one-server collection log treats status as a filter, as the fleet form does. Adds a one-hour tuning-only plan-correction case and extends the census prose.
…wins

# Conflicts:
#	Darling/Darling.Tests/McpPayloadContractCensusTests.cs
… it also takes stat panels and a nested floor
@erikdarlingdata erikdarlingdata changed the title MCP: Lite's config change, collection log, plan correction and memory pressure tools say where their data starts (part of #4966) 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) Oct 5, 2026
The probe seam reset now runs through RunOwnedAsync with a bodySucceeded flag, so a
teardown failure cannot replace the body's own exception.
@erikdarlingdata erikdarlingdata added the merge-train In a merge train: its CI ran on the train PR label Oct 5, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 5, 2026 00:49
@erikdarlingdata
erikdarlingdata merged commit ca571ce into dev Oct 5, 2026
11 of 14 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4966-mcp-lite-config-logs branch October 5, 2026 00:49
erikdarlingdata added a commit that referenced this pull request Oct 5, 2026
…r data starts (part of #4966) (#5171)

Darling's get_blocking, get_deadlocks, get_deadlock_detail, get_blocked_process_xml and get_long_query_completions now say where their data starts, with the contract Lite's twins (#5127) and Darling's session tools (#5147) use: 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, not_collected and unavailable unchanged, and a coverage probe that fails costing the notice, never the rows.

These are event lists and a first collection 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 (DarlingMcpWindowNotice.ReadEventAsync). Coverage is DataWindowFloor's table rule, as in Darling's other coverage notes: the deadlock tools probe deadlocks, get_blocked_process_xml the blocked process reports alone (the XML exists only there), get_blocking both the reports and the DMV blocking snapshots with the earlier winning because its page merges both, and get_long_query_completions its own table. A collector turned on after the server was registered reads as covered from the registration. The web server page's Blocking, Deadlocks, Deadlock Graphs, Blocked Process Reports and Long Query Completions grids now show the tool's own note when its window was cut, as the Top Queries grids already do.

Tests: for each tool, a notice when collection starts inside the window, none on a quiet start, an event older than its collection names the event, hints on an empty answer, the key order, a short window that starts no probe, a capped page, and a failed probe that keeps the rows; the response budgets at the default call all stay inside 32,768 bytes; the census rosters and the shared-formatter pin cover both files.

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

merge-train In a merge train: its CI ran on the train PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant