diff --git a/Darling/Darling.Tests/CustomAlertLastFiredLiveTests.cs b/Darling/Darling.Tests/CustomAlertLastFiredLiveTests.cs new file mode 100644 index 000000000..996a4c575 --- /dev/null +++ b/Darling/Darling.Tests/CustomAlertLastFiredLiveTests.cs @@ -0,0 +1,167 @@ +/* + * 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.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #3360 gated live round-trips (DARLING_TEST_PG) for the "last fired" correlation the rule LIST read +/// () adds. The correlation is pure SQL — a LEFT JOIN of the rule set to +/// a grouped config_alert_log subquery on the immutable Custom:<id> fire key — so it can only be +/// exercised against a real Postgres: a fire row counts, a resolve row for the SAME rule does NOT (it carries a +/// different metric_name), one rule's fires never bleed into another's, and a never-fired rule reports null, +/// all resolved in ONE list read. All test rows are tagged with a per-run prefix / server name and deleted in +/// cleanup, so the shared store is left as it was found. +/// +[Collection("live-postgres")] +public sealed class CustomAlertLastFiredLiveTests +{ + private const string SampleDefinition = + "{\"metric\":{\"source\":\"wait_stats\",\"measure\":\"wait_time_ms\",\"aggregate\":\"sum\",\"hours\":0.25},\"predicate\":{\"op\":\"ge\",\"warnThreshold\":1}}"; + + private static string RequireLivePostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string (owner/superuser) to run the custom-alert last-fired live tests."); + return connectionString!; + } + + private static async Task MigrateAndOpenAsync(string connectionString, CancellationToken ct) + { + await using (var migrate = new NpgsqlConnection(connectionString)) + { + await migrate.OpenAsync(ct); + await PgMigrations.MigrateAsync(migrate, ct); + } + + var dataSourceConnectionString = new NpgsqlConnectionStringBuilder(connectionString) + { + SearchPath = "collect,config,public", + }.ConnectionString; + + return NpgsqlDataSource.Create(dataSourceConnectionString); + } + + /// Inserts ONE config_alert_log row with the given metric_name + naive-UTC alert_time, tagged + /// with so cleanup can delete exactly this run's rows. The other columns are the + /// minimal valid no-channel shape (the correlation reads only metric_name + alert_time). + private static async Task InsertAlertLogAsync( + NpgsqlDataSource dataSource, string serverName, string metricName, DateTime alertTimeUtc, CancellationToken ct) + { + await using var command = dataSource.CreateCommand(@" +INSERT INTO config_alert_log + (alert_time, server_id, server_name, metric_name, current_value, threshold_value, + alert_sent, notification_type, send_error, muted, detail_text, context_json) +VALUES ($1, 1, $2, $3, 0, 0, false, 'none', NULL, false, NULL, NULL)"); + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Timestamp, + Value = DateTime.SpecifyKind(alertTimeUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverName }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = metricName }); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task CreateRuleAsync( + CustomAlertRuleStore store, string name, CancellationToken ct) + { + var result = Assert.IsType( + await store.CreateAsync(name, description: null, SampleDefinition, enabled: true, "lastfiredtest", ct)); + return result.Rule!; + } + + [Fact] + public async Task ListAsync_ReportsLastFired_PerRule_ExcludingResolveRows_InOneRead() + { + var connectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var dataSource = await MigrateAndOpenAsync(connectionString, ct); + + var store = new CustomAlertRuleStore(dataSource); + var prefix = "carlf_" + Guid.NewGuid().ToString("N") + "_"; + var bodySucceeded = false; + try + { + // Three rules: alpha (fires twice, then a resolve), bravo (fires once), charlie (never fires). + var alpha = await CreateRuleAsync(store, prefix + "alpha", ct); + var bravo = await CreateRuleAsync(store, prefix + "bravo", ct); + var charlie = await CreateRuleAsync(store, prefix + "charlie", ct); + + var t0 = new DateTime(2026, 1, 2, 3, 0, 0); + var alphaFire1 = t0; // alpha first fire + var bravoFire = t0.AddMinutes(5); // bravo's only fire + var alphaFire2 = t0.AddMinutes(10); // alpha's LATER fire -> MAX for alpha + var alphaResolveByName = t0.AddMinutes(20); // resolve row for alpha (different metric_name) -> excluded + var alphaResolveLikeKey = t0.AddMinutes(30); // 'Custom: Resolved' -> matches LIKE but NOT the join key + + // alpha's fires (on its immutable fire key) — the later one is the answer. + await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id), alphaFire1, ct); + await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id), alphaFire2, ct); + + // A NATURAL resolve for alpha: metric_name ' Resolved' (DeliverResolveAsync). It is LATER than the + // last fire, so if it were mistaken for a fire alpha's last_fired would be wrong. It must be excluded — + // it does not even match LIKE 'Custom:%'. + await InsertAlertLogAsync(dataSource, prefix, alpha.Name + " Resolved", alphaResolveByName, ct); + + // An ADVERSARIAL row 'Custom: Resolved': it DOES match the LIKE 'Custom:%' pre-filter, and is the + // latest row of all, but is not EXACTLY 'Custom:', so the join key must reject it. This proves the + // exact join (not the LIKE) is what correlates a fire. + await InsertAlertLogAsync( + dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id) + " Resolved", alphaResolveLikeKey, ct); + + // bravo fires once — its own key only. + await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(bravo.Id), bravoFire, ct); + + // charlie: no rows at all. + + // ONE read resolves last_fired for every rule (never a per-rule query). + var summaries = await store.ListAsync(ct); + var alphaSummary = Assert.Single(summaries, s => s.Id == alpha.Id); + var bravoSummary = Assert.Single(summaries, s => s.Id == bravo.Id); + var charlieSummary = Assert.Single(summaries, s => s.Id == charlie.Id); + + // alpha: MAX over its FIRE rows only — the second fire, NOT the (later) resolve rows. + Assert.NotNull(alphaSummary.LastFired); + Assert.Equal(alphaFire2, alphaSummary.LastFired!.Value); + + // bravo: its own single fire — alpha's fires do not bleed in. + Assert.NotNull(bravoSummary.LastFired); + Assert.Equal(bravoFire, bravoSummary.LastFired!.Value); + + // charlie: never fired -> null. + Assert.Null(charlieSummary.LastFired); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString, bodySucceeded, async (cleanup, cleanupCt) => + { + using (var deleteLog = new NpgsqlCommand("DELETE FROM config.config_alert_log WHERE server_name = $1", cleanup)) + { + deleteLog.Parameters.Add(new NpgsqlParameter { TypedValue = prefix }); + await deleteLog.ExecuteNonQueryAsync(cleanupCt); + } + + using var deleteRules = new NpgsqlCommand("DELETE FROM config.custom_alert_rules WHERE name LIKE $1", cleanup); + deleteRules.Parameters.Add(new NpgsqlParameter { TypedValue = prefix + "%" }); + await deleteRules.ExecuteNonQueryAsync(cleanupCt); + }); + } + } +} diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs index 9218ca862..9529c3519 100644 --- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs +++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs @@ -259,7 +259,7 @@ public void BuildRuleSummariesNode_IsABareArray_WithEnabled_AndNoDefinitionBody( var summaries = new List { new(Id: 11, Name: "blocking", Description: null, Enabled: true, Version: 2, - UpdatedAt: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UpdatedBy: "mcp"), + UpdatedAt: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UpdatedBy: "mcp", LastFired: null), }; var array = DarlingWebEndpoints.BuildRuleSummariesNode(summaries); @@ -270,4 +270,28 @@ public void BuildRuleSummariesNode_IsABareArray_WithEnabled_AndNoDefinitionBody( Assert.True((bool)only["enabled"]!); Assert.False(only.ContainsKey("definition")); // the list projection never carries the definition body } + + [Fact] + public void BuildRuleSummariesNode_CarriesLastFired_OrNullWhenNeverFired() + { + var firedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc); + var summaries = new List + { + new(Id: 1, Name: "fired", Description: null, Enabled: true, Version: 1, + UpdatedAt: firedAt, UpdatedBy: null, LastFired: firedAt), + new(Id: 2, Name: "never", Description: null, Enabled: true, Version: 1, + UpdatedAt: firedAt, UpdatedBy: null, LastFired: null), + }; + + var array = DarlingWebEndpoints.BuildRuleSummariesNode(summaries); + + var fired = Assert.IsType(array[0]); + Assert.True(fired.ContainsKey("last_fired")); + Assert.Equal(firedAt, fired["last_fired"]!.GetValue()); + + // The key is ALWAYS present (a stable wire shape) — a never-fired rule carries JSON null, not an absent key. + var never = Assert.IsType(array[1]); + Assert.True(never.ContainsKey("last_fired")); + Assert.Null(never["last_fired"]); + } } diff --git a/Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs b/Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs index e60119b94..5f447abba 100644 --- a/Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs +++ b/Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs @@ -42,11 +42,50 @@ public CustomAlertRuleStore(NpgsqlDataSource dataSource) _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); } - /// The lightweight list read: the (potentially large) definition body is never selected. + /// + /// The lightweight list read: the (potentially large) definition body is never selected. LEFT JOINs + /// each rule to when it LAST FIRED (#3360) — MAX(alert_time) over the config_alert_log rows keyed + /// on the rule's IMMUTABLE fire key 'Custom:' || id (). + /// + /// The correlation is that EXACT fire key only. A resolve / teardown row is written under a DIFFERENT + /// metric_name'<display name> Resolved' (CustomAlertEvaluator.DeliverResolveAsync / + /// WriteTeardownResolutionAsync) — which can never equal 'Custom:<numeric id>', so a resolve + /// is never counted as a fire and one rule's fires never bleed into another's. The LIKE 'Custom:%' is only + /// a pre-filter that shrinks the grouped set; the = 'Custom:' || id join key is what actually correlates, + /// so even a (contrived) row like 'Custom:5 Resolved' — which passes the LIKE — does NOT join to rule 5. + /// + /// Every fire DELIVERY writes a Custom:<id> row regardless of mute / channel (the deliver path + /// records the incident even when muted or channel-less), so MAX(alert_time) over those rows means "the + /// last time the rule's condition fired (the incident was recorded)" — the intended meaning of "last fired" — + /// and the read is deliberately NOT filtered on alert_sent / notification_type. It is ONE + /// round-trip: the grouped subquery scans the fire rows once and joins to the whole rule set, never a per-rule + /// query. The idx_config_alert_log_time (server_id, metric_name, alert_time) index leads with + /// server_id, so this deliberately cross-server aggregate scans rather than seeks — acceptable on this + /// cold, operator-triggered list render over the retention-bounded history table (the per-(server, metric) + /// cooldown seeds still seek that index). A rule that has never fired LEFT-JOINs to a NULL last_fired. + /// public const string ListSql = @" -SELECT id, name, description, enabled, version, updated_at, updated_by -FROM custom_alert_rules -ORDER BY name"; +SELECT + r.id, + r.name, + r.description, + r.enabled, + r.version, + r.updated_at, + r.updated_by, + f.last_fired +FROM custom_alert_rules AS r +LEFT JOIN +( + SELECT + metric_name, + MAX(alert_time) AS last_fired + FROM config_alert_log + WHERE metric_name LIKE 'Custom:%' + GROUP BY metric_name +) AS f + ON f.metric_name = 'Custom:' || r.id +ORDER BY r.name"; /// The full single-rule read (includes definition). $1 id. public const string GetSql = @" @@ -195,7 +234,9 @@ public async Task> ListAsync(CancellationT reader.GetBoolean(3), reader.GetInt32(4), reader.GetDateTime(5), - reader.IsDBNull(6) ? null : reader.GetString(6))); + reader.IsDBNull(6) ? null : reader.GetString(6), + // last_fired: naive-UTC like updated_at (timestamp column -> Kind=Unspecified), NULL when never fired. + reader.IsDBNull(7) ? (DateTime?)null : reader.GetDateTime(7))); } return results; @@ -613,7 +654,10 @@ public sealed record CustomAlertRule( DateTime UpdatedAt, string? UpdatedBy); -/// The lightweight list projection — every field except the definition body. +/// The lightweight list projection — every field except the definition body. +/// (#3360) is the UTC instant this rule most recently FIRED, or null when it never has — see +/// for how it is correlated on the immutable Custom:<id> +/// fire key (resolve rows excluded). public sealed record CustomAlertRuleSummary( long Id, string Name, @@ -621,7 +665,8 @@ public sealed record CustomAlertRuleSummary( bool Enabled, int Version, DateTime UpdatedAt, - string? UpdatedBy); + string? UpdatedBy, + DateTime? LastFired); /// /// The discriminated outcome of a store operation. Closed (the private base constructor blocks external diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 385ef527d..3f608e771 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -2260,7 +2260,10 @@ enabled state is an enabled rule (the real full-document editor always sends the }; /// The bare-array list wire shape (no definition body), carrying enabled so the list can - /// badge a paused rule without fetching each full definition — mirrors the MCP list surface. + /// badge a paused rule without fetching each full definition — plus last_fired (#3360), the naive-UTC + /// ISO-8601 instant the rule most recently fired (JSON null when it never has) so a card can show + /// "last fired …" / "never fired" without a per-rule read. Mirrors the MCP list surface (they share this + /// builder AND , so the field reaches both surfaces identically). internal static JsonArray BuildRuleSummariesNode(IReadOnlyList rules) { var array = new JsonArray(); @@ -2275,6 +2278,7 @@ internal static JsonArray BuildRuleSummariesNode(IReadOnlyList ListCustomAlertRules(NpgsqlDataSource postgres) diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css index 0ed454ad5..b936f0a91 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css @@ -86,6 +86,9 @@ -webkit-box-orient: vertical; } .view-card .vc-meta { font-size: 0.72rem; color: var(--muted); } +/* #3360: "last fired …" — a fired rule reads in --fg to draw the eye to active rules; "never fired" is dimmed. */ +.view-card .vc-fired { font-size: 0.72rem; color: var(--fg); margin-bottom: 0.15rem; } +.view-card .vc-fired.never { color: var(--muted); font-style: italic; } /* enriched card: panel count + distinct viz-type chips (fetched per view — the list summary omits the definition) */ .view-card .vc-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.45rem; } diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alert-rules.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alert-rules.js index c0f455a2e..38b2c4238 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alert-rules.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alert-rules.js @@ -9,9 +9,9 @@ /* * Custom Alert Rules (#3285, Component 7): the list page — the twin of pages/views.js's renderViewList, for * user-authored alert rules instead of custom views. A rule is stored JSON { metric, predicate, hysteresis, scope } - * behind the /api/alerts CRUD; this page lists the summaries as cards (name, enabled/paused, and — enriched per - * card from the rule's definition — its metric, condition, and scope), and offers New rule + a "start from a - * template" menu of the server-authored starter templates. + * behind the /api/alerts CRUD; this page lists the summaries as cards (name, enabled/paused, when it last fired, + * and — enriched per card from the rule's definition — its metric, condition, and scope), and offers New rule + a + * "start from a template" menu of the server-authored starter templates. * * Unlike a custom view, an alert rule has no read-only "rendered" surface — the card links straight to the editor * (alert-editor.js). Edit affordances (New / New from template) show only when the session reports can_edit (the @@ -123,11 +123,14 @@ function templateMenu(templates) { ]); } -/* One rule -> a card linking to its editor. The summary carries name + enabled synchronously; the metric / - condition / scope chips are filled in progressively from the rule's own definition (enrichCard), so the card - renders immediately and never blocks on that fetch. */ +/* One rule -> a card linking to its editor. The summary carries name + enabled + last_fired synchronously; the + metric / condition / scope chips are filled in progressively from the rule's own definition (enrichCard), so the + card renders immediately and never blocks on that fetch. */ function ruleCard(r) { const meta = "v" + r.version + " · updated " + relTime(r.updated_at) + (r.updated_by ? " by " + r.updated_by : ""); + /* last_fired (#3360): the summary correlates the rule's fires (config_alert_log 'Custom:' rows, resolves + excluded) into one field. Non-null -> a relative time via the shared relTime helper; null -> "never fired". */ + const fired = r.last_fired ? "Last fired " + relTime(r.last_fired) : "Never fired"; const chips = el("div", { class: "vc-chips" }); const card = el("a", { class: "view-card card", href: "#/alert-rule/" + encodeURIComponent(r.id) }, [ el("div", { class: "vc-name" }, [ @@ -136,6 +139,7 @@ function ruleCard(r) { ]), r.description ? el("div", { class: "vc-desc", text: r.description }) : null, chips, + el("div", { class: "vc-fired" + (r.last_fired ? "" : " never"), text: fired }), el("div", { class: "vc-meta", text: meta }), ]); enrichCard(r.id, chips);