Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty.

### Changed
- **`get_collection_health` serves what a HEAVY run costs, not only what runs cost on average** ([#2460]) - `query_store` on one dogfood server reported `avg_duration_ms: 13,834` over 1,155 runs. 958 of those runs carried the `enumeration yielded 0 items` note, and an empty enumeration costs **36 ms** - measured on a control server that yields nothing on all 1,551 of its runs and pays 36 ms for every one. Back that out and the remaining 197 PRODUCTIVE runs cost **~80,900 ms EACH**: more than the entire 60,000 ms sweep budget, on their own, once every few cycles. 13,834 ms describes neither population - it is an 83/17 blend that happens to land in a range reading like a plausible single number, and everything downstream inherited it, [#2459]'s brand-new `peak_cycle_ms` included, which understated that server's worst body by ~67,000 ms. Every collector row now carries `p95_duration_ms` and `max_duration_ms` beside `avg_duration_ms`, and `peak_cycle_ms` is built from the p95 rather than the mean. **The store already held this** - `duration_ms` has been written per run since the schema's second rung, and nothing had ever read it as anything but a mean - so this is two aggregates over a table already being grouped, no new collection and no new column. p95 rather than max for the number a decision is made from, because a max is one run and a single pathological cycle would make a collector read as permanently terrible for a week; the max is served BESIDE it as a fact, and comparing the two is what tells a routine tail from a one-off (avg ~ p95 ~ max is one population, avg << p95 is two, p95 << max is one bad run). p95 also scales itself to the sample: over 3,500 runs it discards the outlier, and over the six runs a daily collector gets in a week it lands on the max, which is right, because with six samples there is no outlier anyone can afford to throw away. Each collector is charged the p95 **floored at its mean**, so the aligned cycle can only ever rise - a p95 CAN sit below a mean (99 runs at 10 ms and one at 1,000,000 ms), and taking it unconditionally could have retracted a `BODY_OVERRUN` [#2446] correctly caught. **The verdict is untouched and still amortizes the mean**: sustained demand over a window IS the mean, and a rate built from a tail claims work the server never sustains. On the measured server the aligned body goes from 73,408 ms to 140,507 ms and `peak_collector` changes from `index_object_stats` (the larger MEAN, once a day) to `query_store` (the larger heavy RUN, every five minutes) - which is the collector that was actually overrunning those bodies. `peak_cycle_note` now states the mean beside the heavy run and the gap between them; `heaviest_collectors` rows carry both new statistics and take `pct_of_sweep_budget_per_run` from the heavy run, since a "per run" percentage computed from a mean that describes no run is the defect itself. Both SKUs, one query shape, and the same fixture pinned against live DuckDB and live Postgres so the two engines have to agree - including that both ignore a NULL `duration_ms` in an ordered-set aggregate, which neither query states.
- **`sweep_pressure` answers the single-sweep question as well as the sustained one** ([#2446]) - a server logging six "collection body has not completed after 60-69s of execution - skipping relaunch" warnings in three hours reported `busy_percent: 20.4`, `verdict: OK`, every collector HEALTHY - and both numbers were right about what they measured. [#2296]'s amortized model asks "does this server's total demand fit its cadence on average"; an operator reading a skipped relaunch is asking "did THIS sweep overrun". Those diverge exactly when one collector's single run approaches the budget while its amortized cost is negligible: `index_object_stats` took 37,207 ms of a 60,000 ms body and, at a 1440-minute cadence, contributed 26 ms/min to the verdict. The block now also carries `peak_cycle_ms` / `peak_cycle_percent` - what the body costs on the cycle where every scheduled cadence comes due together, the collectors' averages added WITHOUT being amortized - and `peak_cycle_risk` (FITS / BODY_OVERRUN). That cycle is not a hypothetical worst case: the shipped cadences are strictly nested (1 | 5 | 60 | 1440), so alignment is a guaranteed periodic event, and on that server it costs 73,408 ms against the 60,000 ms budget, which is what the pinned fixture reproduces. `peak_collector` names the collector that owns the most of one sweep and `peak_cycle_note` explains it, because `heaviest_collectors` ranks by amortized contribution and therefore ranks the offending collector out of sight by construction - that list now also carries `amortized_ms_per_minute` and `pct_of_sweep_budget_per_run` per row, so the two costs sit side by side. **The verdict is deliberately unchanged.** A once-daily 37-second collector is not saturation; calling it SATURATED would spend the word on a case whose lever is the schedule's shape rather than the capacity that verdict recommends, and an operator who learns to discount SATURATED loses the signal [#2296] built. Separate field and separate vocabulary, so neither can be read as the other and a fleet scan can filter on either. Measured across the dogfood fleet: the two servers that logged skipped relaunches read OK/BODY_OVERRUN (122% and 109% of budget) while a quiet one read OK/FITS at 19%. The decision stays in the shared SweepPressureClassifier (PerformanceMonitor.Common) with the same table pinned in both suites, and both SKUs' tools serve the identical shape.
Expand Down Expand Up @@ -2845,6 +2848,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2312]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2312
[#2306]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2306
[#2302]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2302
[#2475]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2475
[#2422]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2422
[#2460]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2460
[#2459]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/2459
[#2446]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2446
Expand Down
9 changes: 6 additions & 3 deletions Darling/Darling.Tests/DarlingEmptyEnumerationNoteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,16 @@ public void The_Web_Dashboards_Collection_Health_Table_Shows_The_Note_Too()
/* Darling has THREE Collection Health surfaces, not two: the WPF Viewer grid, the MCP tool, and
the web dashboard's table, which renders whatever COLLECTOR_COLUMNS lists from that same tool's
payload. A field added to the tool but not to that list is silently dropped, leaving the
browser as the one surface still hiding what #1837 exists to show. */
browser as the one surface still hiding what #1837 exists to show.

The array moved from pages/server.js to pages/server-tabs.js when the web server page grew
sub-tabs (#2475) - server.js is the shell now and the tab registry owns every column array. */
var source = ReadRepoFile(Path.Combine(
"Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server.js"));
"Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"));

/* The DEFINITION, not the earlier `columns: COLLECTOR_COLUMNS` use site. */
var start = source.IndexOf("const COLLECTOR_COLUMNS", System.StringComparison.Ordinal);
Assert.True(start >= 0, "server.js must still define COLLECTOR_COLUMNS");
Assert.True(start >= 0, "server-tabs.js must still define COLLECTOR_COLUMNS");
var columns = source[start..];
columns = columns[..columns.IndexOf("];", System.StringComparison.Ordinal)];

Expand Down
Loading
Loading