diff --git a/Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs b/Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs index 111ec57fd..8dd3b64a6 100644 --- a/Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs +++ b/Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs @@ -63,7 +63,7 @@ public void AuthoredTemplate_RoutesEveryRegisteredMetric_ToItsOwnTemplate(string [Fact] public void AuthoredTemplate_NonAuthoredMetric_StaysMechanical() { - var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU"); + var template = AlertNotebookEndpoint.AuthoredTemplate("tempdb Space"); Assert.Null(template); } @@ -244,6 +244,16 @@ public void Budget_ReadCellsHaveLimitsExceptTheTrendRead_ComposedWindowsAreAtMos { Assert.False(parameters.ContainsKey("limit"), $"{read} must not carry a limit param"); } + else if (read == "get_top_queries_by_cpu" || read == "get_top_procedures_by_cpu") + { + /* #4223: these two declare no 'limit' param -- they cap with 'top' instead. */ + Assert.True(parameters.ContainsKey("top"), $"read cell '{read}' must carry a top param"); + } + else if (read == "get_cpu_scheduler_pressure") + { + /* #4223: a newest-snapshot read (DarlingWebEndpoints' own catalog entry) -- no row cap to carry. */ + Assert.False(parameters.ContainsKey("limit"), "get_cpu_scheduler_pressure must not carry a limit param"); + } else { Assert.True(parameters.ContainsKey("limit"), $"read cell '{read}' must carry a limit param"); diff --git a/Darling/Darling.Tests/AlertNotebookEndpointTests.cs b/Darling/Darling.Tests/AlertNotebookEndpointTests.cs index 54f1d1326..3a91e9385 100644 --- a/Darling/Darling.Tests/AlertNotebookEndpointTests.cs +++ b/Darling/Darling.Tests/AlertNotebookEndpointTests.cs @@ -645,8 +645,12 @@ public async Task AuthenticatedRequest_ReturnsTheDocumentedNotebookShape() await using var deadStore = DeadStore(); using var server = await BuildServer(deadStore); + /* #4223 gave "High CPU" an authored template -- this shape check now needs a metric that STAYS + mechanical, so it exercises the fallback path it documents rather than an authored one. "Poison + Wait" is excluded here because a later PR makes it authored too; "tempdb Space" has no row in + s_authoredTemplates. */ var ctx = await SendAuthenticated( - server, "/api/alert-notebook?server=probe&metric=" + Uri.EscapeDataString("High CPU"), + server, "/api/alert-notebook?server=probe&metric=" + Uri.EscapeDataString("tempdb Space"), IPAddress.Parse("192.168.1.50")); Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode); @@ -676,6 +680,6 @@ public async Task AuthenticatedRequest_ReturnsTheDocumentedNotebookShape() Assert.True(cellParams.TryGetProperty("hours", out _), "every mechanical read cell must carry an hours param"); } - Assert.Equal("mechanical/High CPU", root.GetProperty("template").GetProperty("id").GetString()); + Assert.Equal("mechanical/tempdb Space", root.GetProperty("template").GetProperty("id").GetString()); } } diff --git a/Darling/Darling.Tests/AlertNotebookTemplateCpuTests.cs b/Darling/Darling.Tests/AlertNotebookTemplateCpuTests.cs new file mode 100644 index 000000000..82232c479 --- /dev/null +++ b/Darling/Darling.Tests/AlertNotebookTemplateCpuTests.cs @@ -0,0 +1,107 @@ +/* + * 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.Text.Json.Nodes; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins for #4223's authored authored/cpu alert-notebook template +/// ( / BuildCpuCells): routing off the "High CPU" +/// metric, the exact cell list (both engines' CPU timelines present, titled), and the no-matched-incident +/// degrade. The shared theories in AlertNotebookAuthoredTemplateTests (validation, dispatch names, the +/// compose catalog, budget, binding, viz) already cover this family via its +/// row — these pins are the family-specific shape only. +/// +public sealed class AlertNotebookTemplateCpuTests +{ + private static readonly DateTime WindowEnd = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime WindowStart = WindowEnd - TimeSpan.FromHours(24); + private const string AsOf = "2026-01-01T12:00:00Z"; + + private static AlertIncident IncidentWithDatabase(string database) => + new("dedup-key", new[] { "obj1" }, Database: database); + + [Fact] + public void HighCpu_RoutesToTheAuthoredCpuTemplate() + { + var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU"); + + Assert.NotNull(template); + Assert.Equal("authored/cpu", template!.Value.Id); + } + + [Fact] + public void BuildCpuCells_HasTheExactCellListWithBothEnginesPanelsTitled() + { + var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU"); + Assert.NotNull(template); + + var cells = template!.Value.BuildCells( + "High CPU", "SRV1", AsOf, WindowStart, WindowEnd, IncidentWithDatabase("SalesDb"), null, "Unknown"); + + Assert.Equal(7, cells.Count); + + var header = Assert.IsType(cells[0]); + Assert.Equal("header", (string)header["type"]!); + + var status = Assert.IsType(cells[1]); + Assert.Equal("status", (string)status["type"]!); + + var sqlServerCpu = Assert.IsType(cells[2]); + Assert.Equal("panel", (string)sqlServerCpu["type"]!); + Assert.Equal("SQL Server CPU", (string)sqlServerCpu["title"]!); + Assert.Equal("cpu_utilization_stats", (string)sqlServerCpu["source"]!); + Assert.Equal("sqlserver_cpu_utilization", (string)sqlServerCpu["measure"]!); + + var pgCpu = Assert.IsType(cells[3]); + Assert.Equal("panel", (string)pgCpu["type"]!); + Assert.Equal("PostgreSQL CPU", (string)pgCpu["title"]!); + Assert.Equal("pg_cpu_utilization", (string)pgCpu["source"]!); + Assert.Equal("pg_acu_utilization_pct", (string)pgCpu["measure"]!); + + var topQueries = Assert.IsType(cells[4]); + Assert.Equal("read", (string)topQueries["type"]!); + Assert.Equal("get_top_queries_by_cpu", (string)topQueries["read"]!); + Assert.Equal("Top queries by CPU", (string)topQueries["title"]!); + + var topProcedures = Assert.IsType(cells[5]); + Assert.Equal("read", (string)topProcedures["type"]!); + Assert.Equal("get_top_procedures_by_cpu", (string)topProcedures["read"]!); + Assert.Equal("Top procedures by CPU", (string)topProcedures["title"]!); + + var scheduler = Assert.IsType(cells[6]); + Assert.Equal("read", (string)scheduler["type"]!); + Assert.Equal("get_cpu_scheduler_pressure", (string)scheduler["read"]!); + Assert.Equal("Scheduler pressure", (string)scheduler["title"]!); + } + + [Fact] + public void BuildCpuCells_WithNoMatchedIncident_StillDegradesWithTheSameCellCount() + { + var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU"); + Assert.NotNull(template); + + var withoutIncident = template!.Value.BuildCells( + "High CPU", "SRV1", AsOf, WindowStart, WindowEnd, null, null, + "Unknown (not collected since " + AsOf + ")"); + + var withIncident = template.Value.BuildCells( + "High CPU", "SRV1", AsOf, WindowStart, WindowEnd, IncidentWithDatabase("SalesDb"), null, "Unknown"); + + Assert.Equal(withIncident.Count, withoutIncident.Count); + + var definition = new JsonObject { ["kind"] = "notebook", ["cells"] = withoutIncident }; + var validation = DarlingWebEndpoints.ValidateNotebookDefinition(definition); + Assert.True(validation.IsValid, validation.Error); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.Templates.Cpu.cs b/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.Templates.Cpu.cs new file mode 100644 index 000000000..060a11c7a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.Templates.Cpu.cs @@ -0,0 +1,72 @@ +/* + * 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.Globalization; +using System.Text.Json.Nodes; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +internal static partial class AlertNotebookEndpoint +{ + /// High CPU template version (#4223). Bumped only if this template's SHAPE changes. + internal const int CpuTemplateVersion = 1; + + /// High CPU (spec §3): header, status, a SQL Server CPU timeline and a PostgreSQL CPU timeline + /// (the metric fires on both engines since #2719, so both panels are always present — the wrong-engine + /// panel's own read simply comes back empty, the same "not applicable" shape + /// 's "High CPU" row already uses), the top-queries and + /// top-procedures-by-CPU drill-downs, then scheduler pressure for the SQL Server-side follow-up. + private static JsonArray BuildCpuCells( + string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd, + AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status) + { + var cells = new JsonArray + { + HeaderCell(metric, serverName, incident, row), + StatusCell(status), + CpuGaugeTimelinePanel("SQL Server CPU", "cpu_utilization_stats", "sqlserver_cpu_utilization", windowStart, windowEnd), + CpuGaugeTimelinePanel("PostgreSQL CPU", "pg_cpu_utilization", "pg_acu_utilization_pct", windowStart, windowEnd), + AuthoredReadCell("get_top_queries_by_cpu", "Top queries by CPU", serverName, asOf, + ("hours", "24"), ("top", "10")), + AuthoredReadCell("get_top_procedures_by_cpu", "Top procedures by CPU", serverName, asOf, + ("hours", "24"), ("top", "10")), + AuthoredReadCell("get_cpu_scheduler_pressure", "Scheduler pressure", serverName, asOf), + }; + + return cells; + } + + /// Both CPU measures (sqlserver_cpu_utilization, pg_acu_utilization_pct) are Gauge + /// archetype (point-in-time percent readings, per MeasureCatalog's own grain-trap note) — their + /// ValidAggs is avg/min/max, never count. hardcodes + /// aggregate: "count" for the per-event deadlock/blocking sources it was built for, so a CPU + /// timeline needs its own cell rather than reusing that helper (widening it to a per-family aggregate + /// would change what every OTHER family's already-shipped panel emits). No annotation overlay: neither + /// deadlocks nor blocked-process reports nor a long-query completion is a CPU-specific marker, and the + /// spec doesn't ask for one here — see the PR body for why this template carries no core-helper + /// change. + private static JsonObject CpuGaugeTimelinePanel(string title, string source, string measure, DateTime windowStart, DateTime windowEnd) => new() + { + ["type"] = "panel", + ["title"] = title, + ["source"] = source, + ["measure"] = measure, + ["aggregate"] = "avg", + ["viz"] = "line", + ["timeBucket"] = "hour", + ["range"] = new JsonObject + { + ["windowStart"] = windowStart.ToString("o", CultureInfo.InvariantCulture), + ["windowEnd"] = windowEnd.ToString("o", CultureInfo.InvariantCulture), + }, + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs b/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs index 47b6fca6e..8f2addcf8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs +++ b/Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs @@ -378,6 +378,8 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut new AuthoredTemplateEntry("authored/agent-job", AgentJobTemplateVersion, BuildAgentJobCells)), (new[] { "Forced Plan Failing" }, new AuthoredTemplateEntry("authored/forced-plan-failing", ForcedPlanFailingTemplateVersion, BuildForcedPlanFailingCells)), + (new[] { "High CPU" }, + new AuthoredTemplateEntry("authored/cpu", CpuTemplateVersion, BuildCpuCells)), (new[] { "Long-Running Query" }, new AuthoredTemplateEntry("authored/long-running-query", LongRunningQueryTemplateVersion, BuildLongRunningQueryCells)), (new[] { "PostgreSQL Replication Slot Retention" }, @@ -429,6 +431,12 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut internal static readonly IReadOnlySet s_authoredLimitlessTrendReads = new HashSet(StringComparer.Ordinal) { "get_deadlock_trend", + /* #4223: get_top_queries_by_cpu / get_top_procedures_by_cpu / get_cpu_scheduler_pressure declare no + 'limit' param at all -- the first two cap with 'top' (carried explicitly below), the last has no + row cap to carry. */ + "get_top_queries_by_cpu", + "get_top_procedures_by_cpu", + "get_cpu_scheduler_pressure", /* #4223: get_collection_health and get_running_jobs declare only PServer() — no hours/limit/as_of at all — so ServerOnlyReadCell never adds a limit param and this exemption applies here too, even though neither is a trend read. */