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. */