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 @@ + + + + + + + + + + + +