diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fab464d..fda9fb38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Darling viewer: Query Store Regressions sub-tab (Dashboard parity BUILD)** ([#1516]) — the missing Dashboard **Query Store Regressions** view ported to the Darling viewer: the regressed-query GRID over the Query Store data Darling already collects (its `Query Store by Duration` tab + `QueryStoreHistoryWindow` read the same `query_store_stats` store). A new **Queries** sub-tab inserted at index 6 — right after `Query Store by Duration`, matching the Dashboard's Query Store → Query Store Regressions adjacency (every routing reference uses the NAMED constant, so only Expensive Queries / Query Heatmap shift, and the XAML `` order tracks the constants exactly — the same insert pattern as [#1515]). The read (`ViewerDataService.QueryStoreRegressions.cs`) is the Postgres port of the Dashboard's `report.query_store_regressions` inline TVF (`install/47_create_reporting_views.sql`) against `query_store_stats`: two windowed CTEs — BASELINE (every capture BEFORE the window) vs RECENT (the in-window captures) — averaged and joined per `(database, query_id)`, computing the duration / CPU / I/O regression percents, the execution-count-weighted `additional_duration_ms` (extra total time = per-exec duration delta × recent exec count), the plan-count deltas, and a DURATION-driven severity band, keeping only rows whose **CPU regressed > 25%** (the TVF's single-metric gate), ranked by added duration and capped at the TVF's TOP (50). Faithful store adaptations, all documented in the read: the baseline/recent split windows on `collection_time` (Darling's UTC window column — the frame the toolbar's bounds are in) rather than the TVF's `server_last_execution_time` (Darling's column is the server's LOCAL wall clock, so windowing it against UTC bounds would be a timezone bug); the recent query-text sample is `MAX(query_text)` from Darling's already-decompressed column (the TVF `DECOMPRESS()`es a compressed one); and it ports the TVF's ACTUAL behavior (unbounded baseline, plain AVG, CPU-only gate), NOT the stale intent comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages, multi-metric, minimums) that the TVF does not actually run. The row VM reproduces **every Dashboard grid column** (Last Execution, Severity, Database, Query ID, Total Impact, Base/Recent Execs, Base/Recent Plans, Duration Δ%, Base/Recent Dur, CPU Δ%, Base/Recent CPU, I/O Δ%, Base/Recent Reads, Query Text); the grid honors the global #1319 database filter (`$4::text[] IS NULL OR database_name = ANY($4)`), carries a `DataGridFilterManager`, and wires the per-row **double-click execution-history** window (reusing `QueryStoreHistoryWindow` query-scoped with plan_id 0, since the regression aggregates over plans — mirroring the Dashboard grid's double-click). Like the Dashboard grid it has NO slicer / comparison / plan surface (the read is a baseline-vs-recent contrast and carries no plan XML — like the sibling Query Store tab, whose View-Plan is deferred), so it uses the plan-less query-grid menu (`QueryGridRowStyle`) whose **Copy Repro** builds a plan-less repro from the query-text sample. Tests pin the read's load-bearing clauses (base table, the `collection_time` baseline/recent split, the CPU gate, the added-duration ranking + `LIMIT 50`, the severity thresholds, the bigint/integer casts, the µs→ms conversion, the DB filter, and the PG positional dialect via the shared Queries dialect theory), the row display, the double-click key mapper, and Copy Repro, plus a `DARLING_TEST_PG`-gated live round-trip proving baseline-vs-recent detection, the CPU gate, the INNER-JOIN exclusion of NEW (no-baseline) queries, and the CRITICAL severity + added-duration math end-to-end. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes.
- **Darling viewer: live Current Active Queries sub-tab (service-mediated)** ([#1515]) — a LIVE, on-demand DMV snapshot of what is running on a monitored server RIGHT NOW, distinct from the stored **Active Queries** sub-tab (the collector's hourly snapshots). Darling's viewer is store-only, so it is delivered through the Stage-2 command plane exactly like `execute_actual_plan` / `fetch_plan`: the viewer enqueues a new `fetch_active_queries` command, the SERVICE (which holds a live connection to every monitored server) runs the query on the target's live connection and returns the rows in `result_json`, and the viewer renders them in a new **Current Active Queries** sub-tab (Queries sub-index 2, right after the stored Active Queries tab — every routing reference uses the named constant, so only the constant values shift) with a **Refresh** button (ON-DEMAND only — never auto-fetched on tab selection or a range refresh, since a live server hit is an explicit action). The DMV query is REUSED, not reinvented: a new read-only `DarlingCollectorRunner.FetchRowsAsync` (the "fetch phase only" twin of `RunAsync` — same `CollectorContext`, no store write) runs the shared `QuerySnapshotsCollector` against the live connection, so the live snapshot carries the SAME columns as the stored one and renders in the same `ViewerQuerySnapshotRow` grid (the Estimated / Actual plan buttons reuse the stored handlers on the plan the DMV read carried inline). New `CommandKind.FetchActiveQueries` is pinned viewer↔executor (`ViewerDataService.CommandFetchActiveQueries` ↔ `DarlingCommandExecutor.ResolvePlan`), requires `target_server_id` and takes no args, dispatches through `IDarlingCommandHost.FetchActiveQueriesLiveAsync` → `DarlingWorker.RunFetchActiveQueriesLiveAsync` (resolves the runtime under the servers lock, mirroring `RunFetchPlanAsync`), and returns legible `server not monitored` / `server not connected` / `timed out` / `permission denied` / `sql error` outcomes; the service caps the SQL read at 30s (comfortably under the viewer's 60s poll budget so a slow read reports a real outcome, not a poll miss), deletes the (large) command row after the viewer reads it, and — because enqueuing is a config write — a read-only `viewer` seat cannot fetch it (same rule as Pause / `fetch_plan`). It is a read-only DMV read (`sys.dm_exec_requests`/`sessions` + the sql_text / query_plan DMFs), so unlike `execute_actual_plan` it is NOT consent-class. Tests pin the command constant, the `ResolvePlan` case, and the FULL service-serialize → viewer-parse wire contract (no live SQL Server needed), plus the timeout / failure mappings and the poll-budget-vs-SQL-cap ordering; the live executor round-trip is gated on `DARLING_TEST_PG` like the `fetch_plan` one. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes.
- **Lite viewer: Blocking Stats sub-tab (Tier-1 parity BUILD)** ([#1514]) — the Lite port of the Darling viewer's **Blocking Stats** sub-tab: a 2x2 chart grid + a window summary strip showing blocking AND deadlock SEVERITY over the window, appended as the LAST sub-tab of the **Blocking** tab (sub-index 4, after Deadlocks — nothing else reindexed; `RefreshBlockingAsync`'s subTabOnly switch gains `case 4` while the top-level Blocking tab stays index 8). It is the severity companion to Lite's existing Blocking **Trends** sub-tab (which plots blocking/deadlock COUNTS): the four charts plot block Max/Avg duration + block Total duration, then deadlock Max/Avg wait + deadlock Total wait (Total split onto its own axis per pair so the aggregate magnitude doesn't swamp the per-incident one), and the summary strip carries events / total / max / avg block duration, the deadlock count, and the deadlock victim count + total deadlock wait — the Lite equivalent of the Dashboard's pre-aggregated `collect.blocking_deadlock_stats` (`total_blocking_duration_ms` / `victim_count` / `total_deadlock_wait_time_ms`), computed ON-THE-FLY from rows the store already keeps, so no collector or migration is needed. The reader (`LocalDataService.BlockingStats.cs`) adapts Darling's `GetBlockingDurationStatsAsync` + `GetDeadlockSeverityStatsAsync` to DuckDB: the block-duration aggregate SUM/MAX/AVGs `wait_time_ms` per minute over the SAME XE-preferred (`v_blocked_process_reports`) + DMV-fallback (`v_dmv_blocking_snapshots`, `WHERE NOT EXISTS`) union the blocking-incident count trend uses — so the severity chart and the count chart never disagree on which rows exist — and honors the #1319 database filter on both arms; the deadlock-severity aggregate reads the raw `deadlock_graph_xml` over the SAME `v_deadlocks` / `collection_time` window as the deadlock count (uncapped, so it can't drop rows the count keeps), parses each graph OFF the UI thread with Lite's own `DeadlockProcessDetail.ParseFromRows` (the SAME parse the Deadlocks grid uses, so the two agree in-app), and buckets the per-process victim/wait rows by minute (`victim_count` = SUM of victims, total/max/avg over every process's wait). **Collapse check**: Darling's deadlock-severity computation is NOT a viewer↔service twin — the service/MCP side hosts only the count trends and deliberately omits a `get_blocking_deadlock_stats` (no raw victim-wait source), and the Darling viewer's aggregate already parses via the shared `PerformanceMonitor.Common.DeadlockGraphParser` — so per the brief Lite reuses its own existing `ParseFromRows` (keeping Lite's severity internally consistent with its Deadlocks grid) rather than introducing a third copy; nothing on the Darling side was touched. The four charts (`ServerTab.BlockingStats.cs`) mirror Darling's rendering with Lite's chart idioms (shared `ChartStyle` / `ChartHoverHelper`, `SeriesColors` / `ChartPalette` identities, `UtcOffsetMinutes` display conversion, Y-floor-at-0, window-pinned `SetLimitsX`, connected-scatter padded to the window ends), wired into the Blocking `TabControl` + `RefreshBlockingAsync` (both the subTabOnly `case 4` and the full-refresh path) + `ServerTab.xaml.cs` init/dispose. Real-DuckDB round-trip reader tests (`BlockingStatsReaderTests`) plant blocked_process_reports / dmv_blocking_snapshots / deadlocks rows and assert the per-minute duration aggregate (count + total/max/avg), the XE→DMV fallback union, the #1319 filter, window bounding, and the deadlock-severity aggregate (victim_count + total/max/avg wait) reconciling with the deadlock count over the same window.
- **Lite viewer: Expensive Queries sub-tab (Tier-1 parity BUILD)** ([#1513]) — the Lite port of the Darling viewer's **Expensive Queries** view: a unified cross-source ranked list appended as the LAST sub-tab of the **Queries** tab (sub-index 6, after Query Heatmap — nothing else reindexed; the Queries inner switch gains `case 6` while the top-level Queries tab stays index 2). One grid UNIONs the three query engines Lite already collects — `query_stats` (Query Stats), `procedure_stats` (Stored Procedure / Trigger / Function), and `query_store_stats` (Query Store) — each grouped and TOP-20-per-source ranked by AVERAGE worker (CPU) time, then merged, re-ranked by average worker time and capped at 20, mirroring the Dashboard's `report.expensive_queries_today` and Darling's `GetUnifiedExpensiveQueriesAsync`. The reader (`LocalDataService.ExpensiveQueries.cs`) adapts the Postgres/T-SQL UNION to DuckDB against the `v_query_stats` / `v_procedure_stats` / `v_query_store_stats` archive views: the query_stats/query_store arms SUM the collected deltas / weight per-interval averages by `execution_count` (never the raw cumulative DMV totals), a LATERAL fetches each group's latest text + stored plan, every arm is both-sides window-bound on `collection_time` and honors the #1319 database filter, and the µs→ms/sec + KB/page→MB unit conversions run in SQL exactly like the Dashboard view's outer projection. The stored plan rides IN-ROW, so "View Plan" / "Copy Repro Script" open it with zero extra reads via the shared `DataGridContextMenu` — mirroring the Top Queries sub-tab exactly (View Plan on the in-row plan; Get Actual Plan re-executes the runnable statement sources; both no-op cleanly for the module sources and for rows with no plan). Store-driven deviations from the Dashboard view, each faithful to Lite's store: Query Stats objects are the constant `Adhoc` (Lite's query_stats carries no object columns), all functions collapse to a single `Function` label (the shared collector labels them `FUNCTION`), the Query Store grant is pages→MB (×8/1024, unit-correct, matching Lite's Query Store tab), and the Query Store arm carries neither a stored plan nor a plan_handle (Lite's query_store_stats stores neither) so its View Plan / Get Actual Plan gate off. Every Darling grid column is reproduced. Real-DuckDB round-trip reader tests (`ExpensiveQueriesReaderTests`) plant query_stats/procedure_stats/query_store rows and assert the cross-source ranking by avg worker time, the unit conversions, the source labels + object-name shapes, the in-row plan (and the Query Store no-plan/no-handle deviation), delta summing across snapshots, the #1319 filter, and window bounding.
@@ -311,6 +312,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1513]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1513
[#1514]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1514
[#1515]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1515
+[#1516]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1516
[#1505]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1505
[#1504]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1504
[#1503]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1503
diff --git a/Darling/Darling.Tests/ViewerContextMenuTests.cs b/Darling/Darling.Tests/ViewerContextMenuTests.cs
index 562c4117..0e857df8 100644
--- a/Darling/Darling.Tests/ViewerContextMenuTests.cs
+++ b/Darling/Darling.Tests/ViewerContextMenuTests.cs
@@ -187,6 +187,32 @@ public void QueryStoreRow_BuildsFromStoredFields()
Assert.Contains("Source: Query Store", script, StringComparison.Ordinal);
}
+ [Fact]
+ public void QueryStoreRegressionsRow_BuildsPlanLessReproFromTheQueryTextSample()
+ {
+ /* The regression row carries the query-text sample but no plan (it aggregates over plans), so the
+ repro is plan-less even though the caller could pass an enriched plan — the branch ignores it. */
+ var row = new ViewerQueryStoreRegressionRow
+ {
+ QueryTextSample = "SELECT SUM(Amount) FROM dbo.Ledger",
+ DatabaseName = "fin",
+ QueryId = 99,
+ };
+
+ var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: "", Product);
+
+ Assert.NotNull(script);
+ Assert.Contains("SELECT SUM(Amount) FROM dbo.Ledger", script!, StringComparison.Ordinal);
+ Assert.Contains("Source: Query Store Regressions", script, StringComparison.Ordinal);
+ Assert.Contains("USE [fin];", script, StringComparison.Ordinal);
+ Assert.Contains("plan XML not available", script, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void QueryStoreRegressionsRow_WithNoQueryText_ReturnsNull_SoTheHandlerNoOps()
+ => Assert.Null(ViewerServerTab.BuildReproScriptForRow(
+ new ViewerQueryStoreRegressionRow { QueryTextSample = "", DatabaseName = "db" }, enrichedPlanXml: null, Product));
+
[Fact]
public void RowWithNoQueryText_ReturnsNull_SoTheHandlerNoOps()
{
diff --git a/Darling/Darling.Tests/ViewerHistoryWindowTests.cs b/Darling/Darling.Tests/ViewerHistoryWindowTests.cs
index 593aa139..c00c83d7 100644
--- a/Darling/Darling.Tests/ViewerHistoryWindowTests.cs
+++ b/Darling/Darling.Tests/ViewerHistoryWindowTests.cs
@@ -232,6 +232,29 @@ public void MapQueryStoreHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db
=> Assert.Null(ViewerServerTab.MapQueryStoreHistoryKey(
new ViewerQueryStoreRow { DatabaseName = db, QueryId = queryId }));
+ [Fact]
+ public void MapQueryStoreRegressionHistoryKey_CarriesDbQueryIdText_WithZeroPlanId_WhenDbAndQueryIdPresent()
+ {
+ /* The regression row aggregates over plans (no single plan_id), so the query-scoped history window
+ opens with plan_id 0 — the same "no specific plan" value the Dashboard passes. */
+ var key = ViewerServerTab.MapQueryStoreRegressionHistoryKey(new ViewerQueryStoreRegressionRow
+ {
+ DatabaseName = "Sales", QueryId = 42, QueryTextSample = "SELECT 3",
+ });
+ Assert.NotNull(key);
+ Assert.Equal("Sales", key!.DatabaseName);
+ Assert.Equal(42, key.QueryId);
+ Assert.Equal(0, key.PlanId);
+ Assert.Equal("SELECT 3", key.QueryText);
+ }
+
+ [Theory]
+ [InlineData("", 42)] // no database
+ [InlineData("Sales", 0)] // no query_id
+ public void MapQueryStoreRegressionHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, long queryId)
+ => Assert.Null(ViewerServerTab.MapQueryStoreRegressionHistoryKey(
+ new ViewerQueryStoreRegressionRow { DatabaseName = db, QueryId = queryId }));
+
// ── Shared PG positional-dialect assertion (mirrors ViewerDrillDownTests) ──
private static void AssertPgPositionalDialect(string sql)
diff --git a/Darling/Darling.Tests/ViewerQueriesTests.cs b/Darling/Darling.Tests/ViewerQueriesTests.cs
index 472577a6..34b37559 100644
--- a/Darling/Darling.Tests/ViewerQueriesTests.cs
+++ b/Darling/Darling.Tests/ViewerQueriesTests.cs
@@ -204,6 +204,7 @@ public void SlicerSql_BucketsByHour_SevenColumnShape(string sqlName, string tabl
[InlineData(nameof(ViewerDataService.TopQueriesSql))]
[InlineData(nameof(ViewerDataService.TopProceduresSql))]
[InlineData(nameof(ViewerDataService.QueryStoreTopSql))]
+ [InlineData(nameof(ViewerDataService.QueryStoreRegressionsSql))]
[InlineData(nameof(ViewerDataService.QueryStatsComparisonSql))]
[InlineData(nameof(ViewerDataService.ProcedureStatsComparisonSql))]
[InlineData(nameof(ViewerDataService.QueryStoreComparisonSql))]
@@ -225,6 +226,7 @@ public void QueriesReads_ArePostgresDialect_PositionalParams_NoTsqlIsms(string s
nameof(ViewerDataService.TopQueriesSql) => ViewerDataService.TopQueriesSql,
nameof(ViewerDataService.TopProceduresSql) => ViewerDataService.TopProceduresSql,
nameof(ViewerDataService.QueryStoreTopSql) => ViewerDataService.QueryStoreTopSql,
+ nameof(ViewerDataService.QueryStoreRegressionsSql) => ViewerDataService.QueryStoreRegressionsSql,
nameof(ViewerDataService.QueryStatsComparisonSql) => ViewerDataService.QueryStatsComparisonSql,
nameof(ViewerDataService.ProcedureStatsComparisonSql) => ViewerDataService.ProcedureStatsComparisonSql,
nameof(ViewerDataService.QueryStoreComparisonSql) => ViewerDataService.QueryStoreComparisonSql,
@@ -284,6 +286,76 @@ public void ComparisonItems_AreTheSharedUiTypes()
}
}
+///
+/// Pins the Query Store Regressions read (the Dashboard's report.query_store_regressions TVF ported
+/// to Postgres): the baseline-before-window vs. recent-in-window split ON collection_time (not the
+/// TVF's server_last_execution_time), the CPU-regression > 25% gate, the added-duration ranking +
+/// TOP (50) cap, the duration-driven severity bands, the summed/counted CASTs, and the #1319 database
+/// filter — plus the row model's raw-server-clock display. String + pure-logic pins only (no live Postgres).
+///
+public sealed class ViewerQueryStoreRegressionsTests
+{
+ [Fact]
+ public void RegressionsSql_SplitsBaselineBeforeWindow_RecentInWindow_OverTheBaseTable()
+ {
+ var sql = ViewerDataService.QueryStoreRegressionsSql;
+ Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("v_query_store_stats", sql, StringComparison.Ordinal); /* viewer reads base tables */
+ Assert.Contains("collection_time < $2", sql, StringComparison.Ordinal); /* baseline: everything before the window */
+ Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); /* recent: window start */
+ Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); /* recent: window end */
+ Assert.Contains("GROUP BY database_name, query_id", sql, StringComparison.Ordinal);
+ /* Darling windows the split on collection_time — the Dashboard TVF's server_last_execution_time is
+ the server's LOCAL wall clock in Darling's store and must not be windowed against UTC bounds. */
+ Assert.DoesNotContain("server_last_execution_time", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RegressionsSql_GatesOnCpuRegressionOver25_RanksByAddedDuration_CapsAt50_InnerJoinsBaseline()
+ {
+ var sql = ViewerDataService.QueryStoreRegressionsSql;
+ /* The TVF's single-metric gate: CPU regression > 25% (recent vs baseline), NULLIF-guarded. */
+ Assert.Contains("(r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) > 25", sql, StringComparison.Ordinal);
+ /* Extra total time = per-exec duration delta × recent exec count, and the read's ranking. */
+ Assert.Contains("(r.avg_duration_ms - b.avg_duration_ms) * r.exec_count AS additional_duration_ms", sql, StringComparison.Ordinal);
+ Assert.Contains("ORDER BY additional_duration_ms DESC", sql, StringComparison.Ordinal);
+ Assert.Contains("LIMIT 50", sql, StringComparison.Ordinal);
+ /* INNER JOIN — a query with no baseline (NEW) can't regress, exactly like the Dashboard TVF. */
+ Assert.Contains("JOIN baseline_performance", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RegressionsSql_DurationDrivenSeverityBands_MatchTheDashboardThresholds()
+ {
+ var sql = ViewerDataService.QueryStoreRegressionsSql;
+ Assert.Contains("> 100 THEN 'CRITICAL'", sql, StringComparison.Ordinal);
+ Assert.Contains("> 50 THEN 'HIGH'", sql, StringComparison.Ordinal);
+ Assert.Contains("> 25 THEN 'MEDIUM'", sql, StringComparison.Ordinal);
+ Assert.Contains("ELSE 'LOW'", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RegressionsSql_CastsSummedExecCountToBigint_PlanCountToInteger_ConvertsUnits_HonorsDatabaseFilter()
+ {
+ var sql = ViewerDataService.QueryStoreRegressionsSql;
+ Assert.Contains("CAST(SUM(execution_count) AS bigint)", sql, StringComparison.Ordinal);
+ Assert.Contains("CAST(COUNT(DISTINCT plan_id) AS integer)", sql, StringComparison.Ordinal);
+ /* µs → ms on duration + CPU; reads stay raw pages (matching the Dashboard TVF units). */
+ Assert.Contains("AVG(CAST(avg_duration_us AS double precision)) / 1000.0", sql, StringComparison.Ordinal);
+ Assert.Contains("AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0", sql, StringComparison.Ordinal);
+ /* #1319 global database filter, the same guarded ANY() idiom as the sibling reads ($4 here). */
+ Assert.Contains("$4::text[] IS NULL OR database_name = ANY($4)", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RegressionRow_LastExecutionLocal_ShowsRawServerClock_EmptyForNull()
+ {
+ var row = new ViewerQueryStoreRegressionRow { LastExecutionTime = new DateTime(2026, 7, 1, 9, 30, 15) };
+ Assert.Equal("2026-07-01 09:30:15", row.LastExecutionTimeLocal);
+ Assert.Equal("", new ViewerQueryStoreRegressionRow { LastExecutionTime = null }.LastExecutionTimeLocal);
+ }
+}
+
///
/// Gated (DARLING_TEST_PG) live round-trips for the three Queries reads + a comparison + a slicer. Each
/// plants query/procedure/query-store rows for a negative sentinel server across two collections, then
@@ -300,6 +372,7 @@ public sealed class ViewerQueriesLivePostgresTests
private const int ComparisonServerId = -970804;
private const int SlicerServerId = -970805;
private const int ProcedureStatsPlanServerId = -970806;
+ private const int RegressionsServerId = -970807;
private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
@@ -467,6 +540,63 @@ await InsertQueryStoreAsync(connection, QueryStoreServerId, start.AddHours(2), "
}
}
+ [Fact]
+ public async Task QueryStoreRegressions_ContrastsBaselineVsRecent_GatesOnCpu_RanksByAddedDuration_AgainstDevPostgres()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live Query Store regressions test.");
+
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+ await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken);
+ await DeleteRowsAsync(connection, "query_store_stats", RegressionsServerId);
+
+ await using var viewer = new ViewerDataService(cs!);
+ var end = TruncateToSeconds(DateTime.UtcNow);
+ var start = end.AddHours(-24);
+ var baseline = start.AddHours(-2); /* before the window → the baseline arm */
+ var recent = start.AddHours(1); /* inside the window → the recent arm */
+
+ try
+ {
+ /* Query 100 REGRESSED: baseline 2ms dur / 1ms cpu → recent 6ms dur / 4ms cpu.
+ CPU regression = (4-1)/1 = 300% (clears the > 25% gate); duration regression = (6-2)/2 = 200%
+ (CRITICAL band); additional = (6-2) * recent exec 5 = 20 ms. */
+ await InsertQueryStoreAsync(connection, RegressionsServerId, baseline, "StackOverflow", queryId: 100, planId: 1,
+ execCount: 4, avgDurationUs: 2000, avgCpuUs: 1000, forced: false, maxMemPages: 0, queryText: "SELECT regressed");
+ await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 100, planId: 2,
+ execCount: 5, avgDurationUs: 6000, avgCpuUs: 4000, forced: false, maxMemPages: 0, queryText: "SELECT regressed");
+
+ /* Query 200 STABLE: cpu 1ms → 1.1ms = 10% (below the gate) → excluded. */
+ await InsertQueryStoreAsync(connection, RegressionsServerId, baseline, "StackOverflow", queryId: 200, planId: 1,
+ execCount: 5, avgDurationUs: 3000, avgCpuUs: 1000, forced: false, maxMemPages: 0, queryText: "SELECT stable");
+ await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 200, planId: 1,
+ execCount: 5, avgDurationUs: 3000, avgCpuUs: 1100, forced: false, maxMemPages: 0, queryText: "SELECT stable");
+
+ /* Query 300 NEW (recent only, no baseline) → the INNER JOIN drops it. */
+ await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 300, planId: 1,
+ execCount: 9, avgDurationUs: 9000, avgCpuUs: 9000, forced: false, maxMemPages: 0, queryText: "SELECT new");
+
+ var rows = await viewer.GetQueryStoreRegressionsAsync(RegressionsServerId, start, end);
+
+ var r = Assert.Single(rows); /* only query 100 clears the CPU gate + INNER JOIN */
+ Assert.Equal(100, r.QueryId);
+ Assert.Equal("CRITICAL", r.Severity);
+ Assert.Equal(2.0, r.BaselineDurationMs, 3);
+ Assert.Equal(6.0, r.RecentDurationMs, 3);
+ Assert.Equal(200.0, r.DurationRegressionPercent, 1);
+ Assert.Equal(300.0, r.CpuRegressionPercent, 1);
+ Assert.Equal(20.0, r.AdditionalDurationMs, 1); /* (6-2)ms * 5 recent execs */
+ Assert.Equal(4, r.BaselineExecCount);
+ Assert.Equal(5, r.RecentExecCount);
+ Assert.Equal("SELECT regressed", r.QueryTextSample);
+ }
+ finally
+ {
+ await DeleteRowsAsync(connection, "query_store_stats", RegressionsServerId);
+ }
+ }
+
[Fact]
public async Task QueryStatsComparison_FlagsNewAndGone_AcrossWindows_AgainstDevPostgres()
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs
new file mode 100644
index 00000000..5bfa4620
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs
@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitor.Common;
+
+namespace PerformanceMonitor.Darling.Viewer;
+
+///
+/// One Query-Store regression row — the viewer copy of the Dashboard's
+/// QueryStoreRegressionItem (Dashboard/Models/QueryStoreRegressionItem.cs), fed by the Postgres
+/// port of the Dashboard's report.query_store_regressions inline TVF
+/// (install/47_create_reporting_views.sql). A (database, query_id) group's RECENT-window averages vs. its
+/// BASELINE (everything captured before the window), with the per-metric regression percents, the
+/// execution-count-weighted extra duration, the plan-count deltas, a duration-driven severity band, and
+/// the latest captured query-text sample.
+///
+/// Metric values are ms (duration/CPU, converted from µs in SQL) and raw pages (reads); the percents
+/// are plain deltas the grid formats to N2%. is the Query Store row's
+/// last-exec column (the SQL server's local wall clock in Darling's store), shown RAW via
+/// exactly like the sibling Query Store tab — NOT run
+/// through the naive-UTC→local conversion the collection_time columns get. The regression read carries no
+/// plan XML (the Dashboard TVF selects none either), so — like the sibling Query Store tab (View-Plan
+/// deferred) — there is no plan surface; double-clicking a row opens that query's history window instead
+/// (mirroring the Dashboard grid's double-click).
+///
+public sealed class ViewerQueryStoreRegressionRow
+{
+ public string DatabaseName { get; set; } = "";
+ public long QueryId { get; set; }
+ public double BaselineDurationMs { get; set; }
+ public double RecentDurationMs { get; set; }
+ public double DurationRegressionPercent { get; set; }
+ public double BaselineCpuMs { get; set; }
+ public double RecentCpuMs { get; set; }
+ public double CpuRegressionPercent { get; set; }
+ public double BaselineReads { get; set; }
+ public double RecentReads { get; set; }
+ public double IoRegressionPercent { get; set; }
+ public double AdditionalDurationMs { get; set; }
+ public long BaselineExecCount { get; set; }
+ public long RecentExecCount { get; set; }
+ public int BaselinePlanCount { get; set; }
+ public int RecentPlanCount { get; set; }
+ public string Severity { get; set; } = "";
+ public string QueryTextSample { get; set; } = "";
+ public DateTime? LastExecutionTime { get; set; }
+
+ /// The last-execution wall clock shown raw (the sibling Query Store tab's convention).
+ public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime);
+}
+
+public sealed partial class ViewerDataService
+{
+ ///
+ /// The Query-Store regressions read — the Dashboard's report.query_store_regressions(@start,@end)
+ /// inline TVF ported to Postgres against Darling's query_store_stats store. Two windowed CTEs over
+ /// the SAME base table: BASELINE = every capture BEFORE the window start; RECENT = the [start, end]
+ /// window. Each averages the per-interval µs metrics to ms (reads stay raw pages), SUMs execution_count,
+ /// and COUNTs DISTINCT plan_id; the outer projection joins them per (database, query_id), computes the
+ /// duration / CPU / IO regression percents, the execution-count-weighted additional_duration_ms
+ /// (extra total time = per-exec delta × recent exec count), and a DURATION-driven severity band, keeping
+ /// only rows whose CPU regressed > 25% (the TVF's single-metric gate), ranked by additional_duration_ms
+ /// and capped at the TVF's TOP (50).
+ ///
+ /// Faithful adaptations to Darling's store (all reported, none silently changed):
+ /// (1) The Dashboard TVF windows on server_last_execution_time; Darling windows the baseline/recent
+ /// split on collection_time (naive UTC) — the column EVERY other Darling Query Store read windows
+ /// on, and the frame the toolbar's [start,end] bounds are in (Darling's last_execution_time is the
+ /// server's LOCAL wall clock, so windowing on it against UTC bounds would be a timezone bug).
+ /// (2) The recent query-text sample is MAX(query_text) from Darling's already-decompressed
+ /// query_text column (the TVF DECOMPRESS()es a compressed query_sql_text).
+ /// (3) The stale INTENT comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages,
+ /// multi-metric, absolute minimums) does NOT match what the TVF actually runs — this ports the ACTUAL TVF
+ /// (unbounded baseline, plain AVG, CPU-only > 25% gate, no minimums), which is what the Dashboard grid shows.
+ ///
+ /// $1 server_id, $2 window start (baseline is < $2), $3 window end, $4 database filter (text[]).
+ ///
+ public const string QueryStoreRegressionsSql = """
+ WITH baseline_performance AS (
+ SELECT
+ database_name,
+ query_id,
+ AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms,
+ AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms,
+ AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_io_reads,
+ CAST(SUM(execution_count) AS bigint) AS exec_count,
+ CAST(COUNT(DISTINCT plan_id) AS integer) AS plan_count
+ FROM query_store_stats
+ WHERE server_id = $1
+ AND collection_time < $2
+ AND ($4::text[] IS NULL OR database_name = ANY($4))
+ GROUP BY database_name, query_id
+ ),
+ recent_performance AS (
+ SELECT
+ database_name,
+ query_id,
+ MAX(query_text) AS query_text_sample,
+ AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms,
+ AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms,
+ AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_io_reads,
+ CAST(SUM(execution_count) AS bigint) AS exec_count,
+ CAST(COUNT(DISTINCT plan_id) AS integer) AS plan_count,
+ MAX(last_execution_time) AS last_execution_time
+ FROM query_store_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND collection_time <= $3
+ AND ($4::text[] IS NULL OR database_name = ANY($4))
+ GROUP BY database_name, query_id
+ )
+ SELECT
+ r.database_name,
+ r.query_id,
+ b.avg_duration_ms AS baseline_duration_ms,
+ r.avg_duration_ms AS recent_duration_ms,
+ (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) AS duration_regression_percent,
+ b.avg_cpu_time_ms AS baseline_cpu_ms,
+ r.avg_cpu_time_ms AS recent_cpu_ms,
+ (r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) AS cpu_regression_percent,
+ b.avg_logical_io_reads AS baseline_reads,
+ r.avg_logical_io_reads AS recent_reads,
+ (r.avg_logical_io_reads - b.avg_logical_io_reads) * 100.0 / NULLIF(b.avg_logical_io_reads, 0) AS io_regression_percent,
+ (r.avg_duration_ms - b.avg_duration_ms) * r.exec_count AS additional_duration_ms,
+ b.exec_count AS baseline_exec_count,
+ r.exec_count AS recent_exec_count,
+ b.plan_count AS baseline_plan_count,
+ r.plan_count AS recent_plan_count,
+ CASE
+ WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 100 THEN 'CRITICAL'
+ WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 50 THEN 'HIGH'
+ WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 25 THEN 'MEDIUM'
+ ELSE 'LOW'
+ END AS severity,
+ r.query_text_sample,
+ r.last_execution_time
+ FROM recent_performance AS r
+ JOIN baseline_performance AS b
+ ON b.database_name = r.database_name
+ AND b.query_id = r.query_id
+ WHERE (r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) > 25
+ ORDER BY additional_duration_ms DESC
+ LIMIT 50
+ """;
+
+ ///
+ /// The Query Store regressions for one server over the RECENT window [,
+ /// ] vs. the baseline before it, ranked by execution-count-weighted extra
+ /// duration descending (the TVF's ORDER BY; the grid then default-sorts by duration % like the Dashboard).
+ ///
+ public async Task> GetQueryStoreRegressionsAsync(
+ int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList? databaseNames = null, CancellationToken cancellationToken = default)
+ {
+ var rows = new List();
+
+ await using var command = _dataSource.CreateCommand(QueryStoreRegressionsSql);
+ AddServerWindowParameters(command, serverId, startUtc, endUtc);
+ command.Parameters.Add(DatabaseFilterParameter(databaseNames));
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ rows.Add(new ViewerQueryStoreRegressionRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ QueryId = reader.IsDBNull(1) ? 0 : reader.GetInt64(1),
+ BaselineDurationMs = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)),
+ RecentDurationMs = reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)),
+ DurationRegressionPercent = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)),
+ BaselineCpuMs = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)),
+ RecentCpuMs = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)),
+ CpuRegressionPercent = reader.IsDBNull(7) ? 0 : Convert.ToDouble(reader.GetValue(7)),
+ BaselineReads = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)),
+ RecentReads = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)),
+ IoRegressionPercent = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)),
+ AdditionalDurationMs = reader.IsDBNull(11) ? 0 : Convert.ToDouble(reader.GetValue(11)),
+ BaselineExecCount = reader.IsDBNull(12) ? 0 : reader.GetInt64(12),
+ RecentExecCount = reader.IsDBNull(13) ? 0 : reader.GetInt64(13),
+ BaselinePlanCount = reader.IsDBNull(14) ? 0 : reader.GetInt32(14),
+ RecentPlanCount = reader.IsDBNull(15) ? 0 : reader.GetInt32(15),
+ Severity = reader.IsDBNull(16) ? "" : reader.GetString(16),
+ QueryTextSample = reader.IsDBNull(17) ? "" : reader.GetString(17),
+ LastExecutionTime = reader.IsDBNull(18) ? null : reader.GetDateTime(18),
+ });
+ }
+
+ return rows;
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs
index e021ee59..5f24aacd 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs
@@ -126,6 +126,16 @@ private async void CopyReproScript_Click(object sender, RoutedEventArgs e)
qs.QueryText, qs.DatabaseName, enrichedPlanXml, null,
"Query Store", productName: productName);
+ /* Query Store Regressions carries the query-text sample but no plan (the regression aggregates
+ OVER plans — plan_count deltas, not a single plan_id — so any one plan would mislead; the
+ plan-change story is in the double-click history window). Build a plan-less repro from the
+ sample, exactly what ReproScriptBuilder produces when the plan is unavailable. */
+ case ViewerQueryStoreRegressionRow reg:
+ if (string.IsNullOrEmpty(reg.QueryTextSample)) return null;
+ return ReproScriptBuilder.BuildReproScript(
+ reg.QueryTextSample, reg.DatabaseName, null, null,
+ "Query Store Regressions", productName: productName);
+
/* The unified Expensive Queries row carries its STORED plan in-row (ignore enrichedPlanXml, like
the Active Queries snapshot case). Only the statement sources (Query Stats / Query Store) have
runnable text; the procedure sources carry only the object name, so IsStatementSource is false
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs
index 84b3a16a..098b12c4 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs
@@ -41,6 +41,7 @@ public partial class ViewerServerTab : UserControl
private DataGridFilterManager? _queryStatsFilterMgr;
private DataGridFilterManager? _procStatsFilterMgr;
private DataGridFilterManager? _queryStoreFilterMgr;
+ private DataGridFilterManager? _queryStoreRegressionsFilterMgr;
private DataGridFilterManager? _expensiveQueriesFilterMgr;
private DataGridFilterManager? _collectionHealthFilterMgr;
private DataGridFilterManager? _collectionLogFilterMgr;
@@ -67,6 +68,7 @@ private void InitializeFilterManagers()
_queryStatsFilterMgr = new DataGridFilterManager(QueryStatsGrid);
_procStatsFilterMgr = new DataGridFilterManager(ProcedureStatsGrid);
_queryStoreFilterMgr = new DataGridFilterManager(QueryStoreGrid);
+ _queryStoreRegressionsFilterMgr = new DataGridFilterManager(QueryStoreRegressionsGrid);
_expensiveQueriesFilterMgr = new DataGridFilterManager(ExpensiveQueriesGrid);
_collectionHealthFilterMgr = new DataGridFilterManager(CollectionHealthGrid);
_collectionLogFilterMgr = new DataGridFilterManager(CollectionLogGrid);
@@ -85,6 +87,7 @@ private void InitializeFilterManagers()
_filterManagers[QueryStatsGrid] = _queryStatsFilterMgr;
_filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr;
_filterManagers[QueryStoreGrid] = _queryStoreFilterMgr;
+ _filterManagers[QueryStoreRegressionsGrid] = _queryStoreRegressionsFilterMgr;
_filterManagers[ExpensiveQueriesGrid] = _expensiveQueriesFilterMgr;
_filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr;
_filterManagers[CollectionLogGrid] = _collectionLogFilterMgr;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs
index a880b1a1..04500dc5 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs
@@ -47,6 +47,7 @@ private void WireHistoryDrillDowns()
QueryStatsGrid.MouseDoubleClick += QueryStatsGrid_MouseDoubleClick;
ProcedureStatsGrid.MouseDoubleClick += ProcedureStatsGrid_MouseDoubleClick;
QueryStoreGrid.MouseDoubleClick += QueryStoreGrid_MouseDoubleClick;
+ QueryStoreRegressionsGrid.MouseDoubleClick += QueryStoreRegressionsGrid_MouseDoubleClick;
}
/// Maps a Top-Queries grid row to its history-window key, or null when the row can't open one
@@ -70,6 +71,16 @@ private void WireHistoryDrillDowns()
? null
: new QueryStoreHistoryKey(row.DatabaseName, row.QueryId, row.PlanId, row.QueryText);
+ /// Maps a Query Store Regressions grid row to a Query Store history-window key (the window is
+ /// query-scoped), or null when the row can't open one (missing database or a zero query_id) — mirrors the
+ /// Dashboard's QueryStoreRegressionsDataGrid_MouseDoubleClick guard. The regression row aggregates
+ /// over plans (it carries baseline/recent plan COUNTS, not a single plan_id), so plan_id is 0 — the same
+ /// "no specific plan to seed" value the Dashboard passes. Pure + static.
+ internal static QueryStoreHistoryKey? MapQueryStoreRegressionHistoryKey(ViewerQueryStoreRegressionRow? row)
+ => row is null || string.IsNullOrEmpty(row.DatabaseName) || row.QueryId == 0
+ ? null
+ : new QueryStoreHistoryKey(row.DatabaseName, row.QueryId, 0, row.QueryTextSample);
+
private void QueryStatsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (MapQueryStatsHistoryKey(QueryStatsGrid.SelectedItem as ViewerQueryStatsRow) is not { } key) return;
@@ -108,4 +119,17 @@ private void QueryStoreGrid_MouseDoubleClick(object sender, MouseButtonEventArgs
};
window.ShowDialog();
}
+
+ private void QueryStoreRegressionsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (MapQueryStoreRegressionHistoryKey(QueryStoreRegressionsGrid.SelectedItem as ViewerQueryStoreRegressionRow) is not { } key) return;
+
+ var (startUtc, endUtc) = GetWindowUtc();
+ var window = new QueryStoreHistoryWindow(
+ _dataService, _server.ServerId, key.DatabaseName, key.QueryId, key.PlanId, key.QueryText, startUtc, endUtc)
+ {
+ Owner = Window.GetWindow(this),
+ };
+ window.ShowDialog();
+ }
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
index f3ea6045..96a25330 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
@@ -36,18 +36,21 @@ public partial class ViewerServerTab
{
/* Queries sub-tab order — matches Lite's QueriesSubTabControl (W1f-2), plus the Darling-only unified
Expensive Queries grid inserted after the three per-source grids (Dashboard parity), keeping Query
- Heatmap last, and the Darling-only LIVE "Current Active Queries" tab inserted right after the stored
- "Active Queries" tab: Performance Trends, Active Queries, Current Active Queries (live), the three grids,
- Expensive Queries, Query Heatmap. Every reference below uses the NAMED constant, so inserting the live
- tab at index 2 only shifts these values — no literal-index caller needs touching. */
+ Heatmap last, the Darling-only LIVE "Current Active Queries" tab inserted right after the stored
+ "Active Queries" tab, and the Query Store Regressions grid (Dashboard parity) inserted right after
+ Query Store — matching the Dashboard's Query Store → Query Store Regressions adjacency: Performance
+ Trends, Active Queries, Current Active Queries (live), the three grids, Query Store Regressions,
+ Expensive Queries, Query Heatmap. Every reference below uses the NAMED constant, so inserting a tab
+ only shifts these values — no literal-index caller needs touching. */
private const int PerformanceTrendsSubTabIndex = 0;
private const int ActiveQueriesSubTabIndex = 1;
private const int CurrentActiveQueriesSubTabIndex = 2;
private const int TopQueriesSubTabIndex = 3;
private const int TopProceduresSubTabIndex = 4;
private const int QueryStoreSubTabIndex = 5;
- private const int ExpensiveQueriesSubTabIndex = 6;
- private const int QueryHeatmapSubTabIndex = 7;
+ private const int QueryStoreRegressionsSubTabIndex = 6;
+ private const int ExpensiveQueriesSubTabIndex = 7;
+ private const int QueryHeatmapSubTabIndex = 8;
private string _queryStatsSlicerMetric = "TotalCpu";
private List? _queryStatsSlicerData;
@@ -141,6 +144,9 @@ its last snapshot / hint until then. */
case QueryStoreSubTabIndex:
await LoadQueryStoreAsync(startUtc, endUtc);
break;
+ case QueryStoreRegressionsSubTabIndex:
+ await LoadQueryStoreRegressionsAsync(startUtc, endUtc);
+ break;
case ExpensiveQueriesSubTabIndex:
await LoadExpensiveQueriesAsync(startUtc, endUtc);
break;
@@ -194,6 +200,21 @@ private async Task LoadExpensiveQueriesAsync(DateTime startUtc, DateTime endUtc)
SetDefaultSortIfNone(ExpensiveQueriesGrid, "AvgWorkerTimeMs", ListSortDirection.Descending);
}
+ ///
+ /// Loads the Query Store Regressions grid — the Dashboard's regressions view (baseline-vs-recent Query
+ /// Store performance) ported to Darling's store, over the toolbar's settable window. Like the Dashboard
+ /// grid (and unlike the three per-source grids) it has no slicer / comparison / slicer-overlay: the read
+ /// is already a baseline-vs-recent contrast, not a single time series. The default sort matches the
+ /// Dashboard grid's (duration regression % descending) — a grid-view-only SortDescription that does NOT
+ /// touch the read's additional-duration ORDER BY (the TVF's ranking).
+ ///
+ private async Task LoadQueryStoreRegressionsAsync(DateTime startUtc, DateTime endUtc)
+ {
+ var rows = await _dataService.GetQueryStoreRegressionsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter);
+ _queryStoreRegressionsFilterMgr!.UpdateData(rows);
+ SetDefaultSortIfNone(QueryStoreRegressionsGrid, "DurationRegressionPercent", ListSortDirection.Descending);
+ }
+
// ── Slicers (Lite's ServerTab.Slicers.cs; the slicer sends UTC bounds, the viewer reads take naive UTC) ──
private async Task LoadQueryStatsSlicerAsync(DateTime startUtc, DateTime endUtc)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index cc269ebf..39b2b0be 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -1559,6 +1559,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+