From 058da1627f324cf2b498150c275d703f89e289fa Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 21 Sep 2026 15:08:16 +0200 Subject: [PATCH] fix(sla): alert and dashboard judge one rolling 30-day window; an empty period is no data; a lasting breach repeats only on new enrollments The SLA breach alert judged the calendar month while the dashboard headline showed the current ISO week, so an alert could report 78 % while the page it links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached". - SlaEvaluationWindow is the single definition of the SLA populations: success rate and P95 duration on terminal sessions of the last 30 days (rolling), app installs on the install attempts of the current ISO week. The breach evaluator and SlaMetricsService both read it, including the one-decimal rounding, so the value shown is the value judged. - SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays; SlaSnapshot carries Period and HasData. A period without a finished enrollment meets every target flag and is marked HasData = false. - The dashboard reads from whichever starts earlier, the selected months or the evaluation window; trend and violators stay on the selected months. - A lasting breach is notified again only after the cooldown AND once something finished since the last notification. A new breach episode after a resolve still notifies. Resolve notifications are unchanged. - Alert period fact and telemetry tag follow the window constant. - SlaMetricsService takes a nowProvider; the week-boundary workaround in its tests is gone. Co-Authored-By: Claude Fable 5.1 --- .../SlaMetricsServiceTests.cs | 106 +++++++++++-- .../SlaTenantStatusLifecycleTests.cs | 146 +++++++++++++++++- .../Notifications/NotificationAlertBuilder.cs | 4 +- .../Services/SlaBreachEvaluationService.cs | 81 +++++----- .../Services/SlaEvaluationWindow.cs | 90 +++++++++++ .../Services/SlaMetricsService.cs | 79 +++++----- .../src/generated/wire-types.generated.ts | 12 +- .../Models/Metrics/SlaMetrics.cs | 23 ++- .../utils/shared-manifests.json | 36 ++++- .../utils/wire-types.generated.ts | 12 +- 10 files changed, 487 insertions(+), 102 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/SlaEvaluationWindow.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs index 86d3c2c3..f2fcbd3f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs @@ -17,6 +17,10 @@ public class SlaMetricsServiceTests // Use unique tenant IDs per test to avoid static cache collisions private static string NewTenantId() => $"sla-test-{Guid.NewGuid():N}"; + // Fixed clock: Wednesday 2026-09-23, ISO week 39. Mid-week and mid-month, so neither a week + // nor a month boundary can move a fixture out of its period. + private static readonly DateTime Now = new(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + private static (SlaMetricsService Service, string TenantId) CreateService( List sessions, TenantConfiguration? config = null, @@ -44,7 +48,7 @@ public class SlaMetricsServiceTests var logger = NullLogger.Instance; - return (new SlaMetricsService(maintenanceRepo.Object, metricsRepo.Object, configService.Object, logger), tenantId); + return (new SlaMetricsService(maintenanceRepo.Object, metricsRepo.Object, configService.Object, logger, () => Now), tenantId); } private static TenantConfiguration CreateDefaultConfig( @@ -65,15 +69,7 @@ public class SlaMetricsServiceTests int? durationSeconds = null, DateTime? startedAt = null) { - // The CurrentWeek asserts require the session to fall into the CURRENT ISO week - // (SlaMetricsService groups by GetIsoWeekKey, weeks start Monday 00:00 UTC). - // A plain "now - 1h" crosses into LAST week during the first hour of every - // Monday (UTC) and empties CurrentWeek — exactly the CI failure on - // Mon 2026-08-31 00:20Z. Clamp the anchor to the week's Monday instead. - var now = DateTime.UtcNow; - var isoWeekMonday = now.Date.AddDays(-(((int)now.DayOfWeek + 6) % 7)); - var anHourAgo = now.AddHours(-1); - var started = startedAt ?? (anHourAgo >= isoWeekMonday ? anHourAgo : isoWeekMonday); + var started = startedAt ?? Now.AddHours(-1); return new SessionSummary { SessionId = Guid.NewGuid().ToString(), @@ -134,6 +130,96 @@ public class SlaMetricsServiceTests Assert.Equal(0, result.CurrentWeek.SuccessRate); Assert.Equal(0, result.CurrentWeek.TotalCompleted); Assert.Empty(result.Violators); + + // An empty period is "no data": it must not read as a breached target. + foreach (var snapshot in new[] { result.CurrentWeek, result.EvaluationPeriod }) + { + Assert.False(snapshot.HasData); + Assert.True(snapshot.SuccessRateMet); + Assert.True(snapshot.DurationTargetMet); + } + } + + [Fact] + public async Task ComputeSlaMetrics_EmptyWeekFilledWindow_HeadlineCarriesTheEvaluationWindow() + { + // The evaluation window holds finished enrollments, the current ISO week none — the + // headline snapshot (what the breach notification evaluates) reports them, the week stays empty. + var twoWeeksAgo = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); // W37 + var sessions = new List(); + for (var i = 0; i < 32; i++) sessions.Add(CreateSession(SessionStatus.Succeeded, 1800, twoWeeksAgo)); + for (var i = 0; i < 9; i++) sessions.Add(CreateSession(SessionStatus.Failed, 1800, twoWeeksAgo)); + + var (service, tenantId) = CreateService(sessions); + var result = await service.ComputeSlaMetricsAsync(tenantId, 1); + + Assert.Equal(30, result.EvaluationWindowDays); + Assert.Equal("last-30-days", result.EvaluationPeriod.Period); + Assert.Equal("", result.EvaluationPeriod.Week); + Assert.True(result.EvaluationPeriod.HasData); + Assert.Equal(41, result.EvaluationPeriod.TotalCompleted); + Assert.Equal(78, result.EvaluationPeriod.SuccessRate); + Assert.False(result.EvaluationPeriod.SuccessRateMet); + + Assert.Equal("2026-W39", result.CurrentWeek.Period); + Assert.Equal("2026-W39", result.CurrentWeek.Week); + Assert.False(result.CurrentWeek.HasData); + Assert.True(result.CurrentWeek.SuccessRateMet); + } + + [Fact] + public async Task ComputeSlaMetrics_EvaluationWindow_IsRolling_NotTheCalendarMonth() + { + // Now = 2026-09-23 12:00 ⇒ the window starts 2026-08-24 12:00. + var sessions = new List + { + CreateSession(SessionStatus.Succeeded, 1800), + // previous calendar month, inside the rolling window + CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 25, 9, 0, 0, DateTimeKind.Utc)), + // just outside the window + CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 24, 11, 59, 59, DateTimeKind.Utc)), + CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 7, 15, 9, 0, 0, DateTimeKind.Utc)), + }; + + var (service, tenantId) = CreateService(sessions); + var result = await service.ComputeSlaMetricsAsync(tenantId, 3); + + Assert.Equal(2, result.EvaluationPeriod.TotalCompleted); + Assert.Equal(50, result.EvaluationPeriod.SuccessRate); + Assert.Equal(4, result.WeeklyTrend.Sum(w => w.TotalCompleted)); + } + + [Fact] + public async Task ComputeSlaMetrics_OneMonthSelection_HeadlineStillReachesBack_TrendDoesNot() + { + // "1 Month" starts on the first; the evaluation window reaches into August. The headline + // must count the August session, the trend and the violators must not grow by it. + var sessions = new List + { + CreateSession(SessionStatus.Succeeded, 1800), + CreateSession(SessionStatus.Failed, 1800, new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc)), + }; + + var (service, tenantId) = CreateService(sessions); + var result = await service.ComputeSlaMetricsAsync(tenantId, 1); + + Assert.Equal(2, result.EvaluationPeriod.TotalCompleted); + Assert.Equal(1, result.WeeklyTrend.Sum(w => w.TotalCompleted)); + Assert.Empty(result.Violators); + } + + [Fact] + public async Task ComputeSlaMetrics_SessionsWithoutDuration_DurationTargetIsNotJudged() + { + var config = CreateDefaultConfig(targetMaxDuration: 30); + var sessions = new List { CreateSession(SessionStatus.Failed) }; + + var (service, tenantId) = CreateService(sessions, config); + var result = await service.ComputeSlaMetricsAsync(tenantId, 1); + + Assert.True(result.EvaluationPeriod.HasData); + Assert.False(result.EvaluationPeriod.SuccessRateMet); + Assert.True(result.EvaluationPeriod.DurationTargetMet); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs index 92bc9c9b..8d43e06f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs @@ -213,6 +213,14 @@ public class SlaTenantStatusLifecycleTests .ReturnsAsync(sessions); } + /// Like the real repository: only sessions started inside the queried range come back. + public void SetSessionsHonoringDateRange(string tenantId, List sessions) + { + _maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny(), It.IsAny(), tenantId, It.IsAny())) + .ReturnsAsync((DateTime start, DateTime end, string? _, CancellationToken _) => + sessions.Where(s => s.StartedAt >= start && s.StartedAt < end).ToList()); + } + public void SetRecentSessions(string tenantId, List sessions) { _sessionRepo.Setup(r => r.GetSessionsPageAsync(tenantId, It.IsAny(), It.IsAny(), It.IsAny())) @@ -291,7 +299,7 @@ public class SlaTenantStatusLifecycleTests } [Fact] - public async Task ReBreach_AfterCooldown_SendsAnotherNotification() + public async Task ReBreach_AfterCooldown_WithNewlyFinishedSession_SendsAnotherNotification() { var h = new Harness(); var config = CreateConfig(successRate: true); @@ -307,6 +315,15 @@ public class SlaTenantStatusLifecycleTests row!.SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddHours(-25); await h.StatusRepo.UpsertAsync(row); + // A session that finished after the last notification: the population changed. + var sessions = Sessions(succeeded: 8, failed: 4); + sessions.Add(new SessionSummary + { + TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = SessionStatus.Failed, + StartedAt = DateTime.UtcNow.AddHours(-26), CompletedAt = DateTime.UtcNow.AddHours(-1), DurationSeconds = 1800, + }); + h.SetTerminalSessions(TenantId, sessions); + await h.Service.EvaluateAllTenantsAsync(); Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach")); @@ -314,6 +331,67 @@ public class SlaTenantStatusLifecycleTests Assert.True((DateTime.UtcNow - afterRow!.SuccessRate_LastNotifiedAt!.Value).TotalMinutes < 1); } + /// + /// A lasting breach without any newly finished enrollment is not repeated, however long the + /// cooldown has expired — a tenant that enrolls nothing must not get the same number every day. + /// Includes a session that finished BEFORE the last notification (an imported or merely old + /// row): it is not new evidence. + /// + [Fact] + public async Task ReBreach_AfterCooldown_WithoutNewSession_StaysSilent() + { + var h = new Harness(); + var config = CreateConfig(successRate: true, duration: true, targetMaxDurationMinutes: 10); + h.SetTenants(config); + var sessions = Sessions(succeeded: 8, failed: 4); // 66.7 % and P95 30 min: both breach + sessions.Add(new SessionSummary + { + TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = SessionStatus.Failed, + StartedAt = DateTime.UtcNow.AddDays(-9), CompletedAt = DateTime.UtcNow.AddDays(-9).AddMinutes(30), DurationSeconds = 1800, + }); + h.SetTerminalSessions(TenantId, sessions); + + await h.Service.EvaluateAllTenantsAsync(); + Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach")); + + var row = await h.StatusRepo.GetAsync(TenantId); + row!.SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddDays(-5); + row.Duration_LastNotifiedAt = DateTime.UtcNow.AddDays(-5); + await h.StatusRepo.UpsertAsync(row); + + await h.Service.EvaluateAllTenantsAsync(); + + Assert.Equal(2, h.NotificationsSent.Count(n => n.Type == "sla_breach")); + var afterRow = await h.StatusRepo.GetAsync(TenantId); + Assert.True(afterRow!.SuccessRate_IsActive); // still breaching, still tracked + Assert.True(afterRow.Duration_IsActive); + Assert.NotNull(afterRow.SuccessRate_LastBreachAt); + } + + /// + /// A new breach episode after a resolve notifies without new evidence: the rolling window can + /// re-breach by good sessions aging out, and the admin was last told "resolved". + /// + [Fact] + public async Task NewBreachEpisode_AfterResolve_NotifiesWithoutNewSession() + { + var h = new Harness(); + var config = CreateConfig(successRate: true); + h.SetTenants(config); + await h.StatusRepo.UpsertAsync(new SlaTenantStatus + { + TenantId = TenantId, + SuccessRate_IsActive = false, + SuccessRate_LastNotifiedAt = DateTime.UtcNow.AddDays(-3), + SuccessRate_ResolvedAt = DateTime.UtcNow.AddDays(-2), + }); + h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4)); // no CompletedAt after the watermark + + await h.Service.EvaluateAllTenantsAsync(); + + Assert.Single(h.NotificationsSent, n => n.Type == "sla_breach"); + } + [Fact] public async Task BreachResolved_SetsIsActiveFalseAndSendsResolvedNotification() { @@ -773,6 +851,68 @@ public class SlaTenantStatusLifecycleTests Assert.DoesNotContain(h.NotificationsSent, n => n.Type == "sla_breach"); } + /// + /// The breach notification links to the SLA dashboard, so both must report the same number + /// for the same target. Same sessions through the evaluator and through SlaMetricsService: + /// the persisted breach values equal the dashboard's evaluation-period snapshot. The fixture + /// holds sessions of the previous calendar month inside AND outside the rolling window and an + /// empty current ISO week on purpose — a headline computed on a calendar month, on the ISO + /// week or on the selected months diverges here. Both month selections run: with "1 Month" + /// the dashboard's own range starts after the window does. + /// + [Theory] + [InlineData(1)] + [InlineData(3)] + public async Task Evaluator_And_DashboardEvaluationPeriod_ReportTheSameValues(int months) + { + var clock = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc); // Monday, ISO week 39; window starts 08-22 12:00 + SessionSummary At(DateTime startedAt, SessionStatus status, int durationSec) => new() + { + TenantId = TenantId, SessionId = Guid.NewGuid().ToString(), Status = status, + StartedAt = startedAt, DurationSeconds = durationSec, + }; + + var sessions = new List(); + for (int i = 0; i < 4; i++) // previous month, outside the rolling window + sessions.Add(At(new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 9000)); + for (int i = 0; i < 2; i++) // previous month, inside the rolling window + sessions.Add(At(new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 5400)); + for (int i = 0; i < 7; i++) // this month, earlier ISO weeks + sessions.Add(At(new DateTime(2026, 9, 3 + i, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Succeeded, 900 + i * 600)); + sessions.Add(At(new DateTime(2026, 9, 9, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 7200)); + sessions.Add(At(new DateTime(2026, 9, 10, 9, 0, 0, DateTimeKind.Utc), SessionStatus.Failed, 300)); + + var h = new Harness(clock: clock); + var config = CreateConfig(successRate: true, duration: true, targetMaxDurationMinutes: 30); + h.SetTenants(config); + h.SetSessionsHonoringDateRange(TenantId, sessions); + + await h.Service.EvaluateAllTenantsAsync(); + var row = await h.StatusRepo.GetAsync(TenantId); + + var maintenanceRepo = new Mock(); + maintenanceRepo.Setup(r => r.GetSessionsByDateRangeAsync(It.IsAny(), It.IsAny(), TenantId, It.IsAny())) + .ReturnsAsync((DateTime start, DateTime end, string? _, CancellationToken _) => + sessions.Where(s => s.StartedAt >= start && s.StartedAt < end).ToList()); + var configService = new Mock( + Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + configService.Setup(c => c.GetConfigurationAsync(TenantId)).ReturnsAsync(config); + var dashboard = new SlaMetricsService( + maintenanceRepo.Object, Mock.Of(), configService.Object, + NullLogger.Instance, () => clock); + + var metrics = await dashboard.ComputeSlaMetricsAsync(TenantId, months, fresh: true); + + Assert.Equal(11, metrics.EvaluationPeriod.TotalCompleted); + Assert.Equal(63.6, metrics.EvaluationPeriod.SuccessRate); + Assert.Equal(row!.SuccessRate_CurrentValue, metrics.EvaluationPeriod.SuccessRate); + Assert.Equal(row.SuccessRate_TotalSessions, metrics.EvaluationPeriod.TotalCompleted); + Assert.Equal(row.Duration_CurrentP95Minutes, metrics.EvaluationPeriod.P95DurationMinutes); + Assert.Equal(row.SuccessRate_IsActive, !metrics.EvaluationPeriod.SuccessRateMet); + Assert.Equal(row.Duration_IsActive, !metrics.EvaluationPeriod.DurationTargetMet); + Assert.False(metrics.CurrentWeek.HasData); + } + /// /// Review-finding regression: AppInstall notification + ops event must carry the actual /// installer totals (not 0/0). Without this, Application Insights / Ops dashboards see @@ -877,8 +1017,8 @@ public class SlaTenantStatusLifecycleTests /// ops dashboards even though they were evaluated on the current ISO week. /// [Theory] - [InlineData("SuccessRate", "CurrentMonth")] - [InlineData("Duration", "CurrentMonth")] + [InlineData("SuccessRate", "Last30Days")] + [InlineData("Duration", "Last30Days")] [InlineData("AppInstall", "CurrentWeek")] public void PeriodForBreachType_MatchesEvaluationWindow(string breachType, string expectedPeriod) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs index cad53ae3..c4d638a3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs @@ -311,7 +311,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications label = "Success Rate"; title = $"\ud83d\udfe1 SLA Breach: Success Rate {currentRate:F1}%"; summary = $"SLA breach: success rate {currentRate:F1}% is below target {targetRate:F1}%"; - period = "Current Month"; + period = $"Last {SlaEvaluationWindow.WindowDays} days"; facts.Add(new NotificationFact { Name = "Breach Type", Value = label }); facts.Add(new NotificationFact { Name = "Current Rate", Value = $"{currentRate:F1}%" }); facts.Add(new NotificationFact { Name = "Target Rate", Value = $"{targetRate:F1}%" }); @@ -323,7 +323,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications label = "Duration (P95)"; title = "\ud83d\udfe1 SLA Breach: P95 Duration Exceeds Target"; summary = $"SLA breach: P95 duration {currentRate:F1}min exceeds target {targetRate:F0}min"; - period = "Current Month"; + period = $"Last {SlaEvaluationWindow.WindowDays} days"; facts.Add(new NotificationFact { Name = "Breach Type", Value = label }); facts.Add(new NotificationFact { Name = "Current P95", Value = $"{currentRate:F1} min" }); facts.Add(new NotificationFact { Name = "Target Max", Value = $"{targetRate:F0} min" }); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 769c7883..0118b123 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -176,15 +176,12 @@ namespace AutopilotMonitor.Functions.Services { // Compute observables once outside the retry loop — they don't change across retries. var now = _nowProvider(); - var monthStart = new DateTime(now.Year, now.Month, 1); - var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(monthStart, now.AddDays(1), config.TenantId); - var terminal = sessions - .Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed) - .ToList(); + // Populations come from SlaEvaluationWindow — the same definitions the SLA dashboard + // headline uses, so a notification and the page it links to show the same number. + var windowStart = SlaEvaluationWindow.WindowStart(now); + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(windowStart, now.AddDays(1), config.TenantId); + var terminal = sessions.Where(SlaEvaluationWindow.IsTerminal).ToList(); - // AppInstall is evaluated on the same current-ISO-week scope as the SLA dashboard - // (SlaMetricsService.cs:170, SectionSlaTargets.tsx). Without this alignment a - // notification could fire while the UI shows green for the week (or vice versa). List? appInstalls = null; if (config.SlaNotifyOnAppInstallBreach && config.SlaTargetAppInstallSuccessRate.HasValue) { @@ -193,13 +190,7 @@ namespace AutopilotMonitor.Functions.Services // now-8d never excludes a current-week row). The exact week match happens in-memory // below; this just stops the query from scanning the tenant's entire history every 2h. var raw = await _metricsRepo.GetAppInstallSummariesByTenantAsync(config.TenantId, now.AddDays(-8)); - appInstalls = raw - .Where(a => (a.Status == "Succeeded" || a.Status == "Failed") - // PR0 (2026-07-26): skips are not install attempts — keep the breach - // evaluation on the same population as the SLA dashboard rate. - && !Helpers.MetricsMath.IsSkipTerminalState(a) - && SlaMetricsService.GetIsoWeekKey(a.StartedAt) == currentWeekKey) - .ToList(); + appInstalls = SlaEvaluationWindow.AppInstallAttemptsInWeek(raw, currentWeekKey); } // Recent-page query is also stable for the cycle (timer view of "is breach still going"). @@ -222,9 +213,8 @@ namespace AutopilotMonitor.Functions.Services // --- SuccessRate --- if (config.SlaNotifyOnSuccessRateBreach && config.SlaTargetSuccessRate.HasValue && terminal.Count > 0) { - var succeeded = terminal.Count(s => s.Status == SessionStatus.Succeeded); var failed = terminal.Count(s => s.Status == SessionStatus.Failed); - var successRate = succeeded / (double)terminal.Count * 100; + var successRate = SlaEvaluationWindow.SuccessRate(terminal); var target = (double)config.SlaTargetSuccessRate.Value; var threshold = config.SlaSuccessRateNotifyThreshold.HasValue ? (double)config.SlaSuccessRateNotifyThreshold.Value @@ -233,7 +223,8 @@ namespace AutopilotMonitor.Functions.Services if (isBreaching) breaches++; ApplySuccessRateState(status, now, isBreaching, successRate, target, threshold, - terminal.Count, failed, cooldown, pending, config); + terminal.Count, failed, cooldown, pending, config, + HasNewEvidence(status.SuccessRate_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(terminal, t))); } else if (status.SuccessRate_IsActive) { @@ -243,21 +234,18 @@ namespace AutopilotMonitor.Functions.Services // --- Duration --- if (config.SlaNotifyOnDurationBreach && config.SlaTargetMaxDurationMinutes.HasValue) { - var completed = terminal - .Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0) - .Select(s => s.DurationSeconds!.Value / 60.0) - .OrderBy(d => d) - .ToList(); + var completed = SlaEvaluationWindow.DurationsMinutes(terminal); if (completed.Count > 0) { - var p95 = MetricsMath.Percentile(completed, 95); + var p95 = SlaEvaluationWindow.P95Minutes(completed); var target = config.SlaTargetMaxDurationMinutes.Value; var isBreaching = p95 > target; if (isBreaching) breaches++; ApplyDurationState(status, now, isBreaching, p95, target, completed.Count, - cooldown, pending, config); + cooldown, pending, config, + HasNewEvidence(status.Duration_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(terminal, t))); } else if (status.Duration_IsActive) { @@ -274,9 +262,8 @@ namespace AutopilotMonitor.Functions.Services { if (appInstalls.Count >= MinAppInstallSampleSize) { - var succeeded = appInstalls.Count(a => a.Status == "Succeeded"); var failed = appInstalls.Count(a => a.Status == "Failed"); - var rate = succeeded / (double)appInstalls.Count * 100; + var rate = SlaEvaluationWindow.AppInstallSuccessRate(appInstalls); var target = (double)config.SlaTargetAppInstallSuccessRate.Value; var topFailing = appInstalls .Where(a => a.Status == "Failed") @@ -288,7 +275,8 @@ namespace AutopilotMonitor.Functions.Services if (isBreaching) breaches++; ApplyAppInstallState(status, now, isBreaching, rate, target, topFailing, - appInstalls.Count, failed, cooldown, pending, config); + appInstalls.Count, failed, cooldown, pending, config, + HasNewEvidence(status.AppInstall_LastNotifiedAt, t => SlaEvaluationWindow.HasNewSince(appInstalls, t))); } else if (status.AppInstall_IsActive) { @@ -375,7 +363,7 @@ namespace AutopilotMonitor.Functions.Services private void ApplySuccessRateState(SlaTenantStatus s, DateTime now, bool isBreaching, double current, double target, double threshold, int total, int failed, - TimeSpan cooldown, List> pending, TenantConfiguration config) + TimeSpan cooldown, List> pending, TenantConfiguration config, bool hasNewEvidence) { if (isBreaching) { @@ -391,8 +379,7 @@ namespace AutopilotMonitor.Functions.Services s.SuccessRate_LastBreachAt = now; s.SuccessRate_ResolvedAt = null; - var shouldNotify = !s.SuccessRate_LastNotifiedAt.HasValue - || (now - s.SuccessRate_LastNotifiedAt.Value) >= cooldown; + var shouldNotify = ShouldNotify(s.SuccessRate_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence); if (shouldNotify) { @@ -425,7 +412,7 @@ namespace AutopilotMonitor.Functions.Services private void ApplyDurationState(SlaTenantStatus s, DateTime now, bool isBreaching, double p95, int targetMinutes, int totalSessions, - TimeSpan cooldown, List> pending, TenantConfiguration config) + TimeSpan cooldown, List> pending, TenantConfiguration config, bool hasNewEvidence) { if (isBreaching) { @@ -439,8 +426,7 @@ namespace AutopilotMonitor.Functions.Services s.Duration_LastBreachAt = now; s.Duration_ResolvedAt = null; - var shouldNotify = !s.Duration_LastNotifiedAt.HasValue - || (now - s.Duration_LastNotifiedAt.Value) >= cooldown; + var shouldNotify = ShouldNotify(s.Duration_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence); if (shouldNotify) { @@ -469,7 +455,7 @@ namespace AutopilotMonitor.Functions.Services private void ApplyAppInstallState(SlaTenantStatus s, DateTime now, bool isBreaching, double currentRate, double targetRate, string? topFailingApp, int totalInstalls, int failedInstalls, - TimeSpan cooldown, List> pending, TenantConfiguration config) + TimeSpan cooldown, List> pending, TenantConfiguration config, bool hasNewEvidence) { if (isBreaching) { @@ -483,8 +469,7 @@ namespace AutopilotMonitor.Functions.Services s.AppInstall_LastBreachAt = now; s.AppInstall_ResolvedAt = null; - var shouldNotify = !s.AppInstall_LastNotifiedAt.HasValue - || (now - s.AppInstall_LastNotifiedAt.Value) >= cooldown; + var shouldNotify = ShouldNotify(s.AppInstall_LastNotifiedAt, now, cooldown, firstBreach, hasNewEvidence); if (shouldNotify) { @@ -747,6 +732,22 @@ namespace AutopilotMonitor.Functions.Services private static int EffectiveConsecutiveFailureThreshold(TenantConfiguration config) => config.SlaConsecutiveFailureThreshold < 2 ? 5 : config.SlaConsecutiveFailureThreshold; + private static bool HasNewEvidence(DateTime? lastNotifiedAt, Func hasNewSince) + => !lastNotifiedAt.HasValue || hasNewSince(lastNotifiedAt.Value); + + // A lasting breach is repeated only when something new finished since the last + // notification; the cooldown stays the lower bound. A tenant without enrollments would + // otherwise receive the same number every cooldown. A new breach episode (first breach + // after a resolve) needs no new evidence — the rolling window can re-breach by good + // sessions aging out, and the admin was last told "resolved". + private static bool ShouldNotify(DateTime? lastNotifiedAt, DateTime now, TimeSpan cooldown, + bool firstBreach, bool hasNewEvidence) + { + if (!lastNotifiedAt.HasValue) return true; + if (now - lastNotifiedAt.Value < cooldown) return false; + return firstBreach || hasNewEvidence; + } + private async Task GetCooldownAsync() { var admin = await _adminConfigService.GetConfigurationAsync(); @@ -755,12 +756,12 @@ namespace AutopilotMonitor.Functions.Services } // Telemetry tag — must match the actual evaluation window so Application Insights / - // ops dashboards don't group AppInstall breaches under "CurrentMonth". + // ops dashboards don't group AppInstall breaches under the session window. internal static string PeriodForBreachType(string breachType) => breachType switch { SlaBreachType.AppInstall => "CurrentWeek", - SlaBreachType.SuccessRate => "CurrentMonth", - SlaBreachType.Duration => "CurrentMonth", + SlaBreachType.SuccessRate => $"Last{SlaEvaluationWindow.WindowDays}Days", + SlaBreachType.Duration => $"Last{SlaEvaluationWindow.WindowDays}Days", _ => "CurrentPeriod", }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaEvaluationWindow.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaEvaluationWindow.cs new file mode 100644 index 00000000..d2f024c7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaEvaluationWindow.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// The one definition of what the SLA targets are measured on. Both the breach evaluator + /// (notifications) and the SLA dashboard headline read it, so an alert and the page it links + /// to can never report different numbers for the same target. + /// Success rate + P95 duration: terminal sessions started in the last days. + /// App installs: install attempts of the current ISO week. + /// The window is rolling on purpose: a calendar month empties on its first day, which silently + /// closes an active breach and blanks the headline until the next enrollment finishes. + /// + internal static class SlaEvaluationWindow + { + public const int WindowDays = 30; + + /// Period key of the rolling window on SlaSnapshot.Period. + public const string PeriodKey = "last-30-days"; + + public static DateTime WindowStart(DateTime nowUtc) => nowUtc.AddDays(-WindowDays); + + public static bool IsTerminal(SessionSummary s) + => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed; + + public static List TerminalInWindow(IEnumerable sessions, DateTime nowUtc) + { + var windowStart = WindowStart(nowUtc); + return sessions.Where(s => IsTerminal(s) && s.StartedAt >= windowStart).ToList(); + } + + /// + /// Succeeded / terminal * 100, one decimal. Target comparisons use this rounded value — + /// the number the admin reads is the number that is judged. 0 for an empty set; callers + /// treat an empty set as "no data", never as a breach. + /// + public static double SuccessRate(IReadOnlyCollection terminal) + { + if (terminal.Count == 0) return 0; + var succeeded = terminal.Count(s => s.Status == SessionStatus.Succeeded); + return Math.Round(succeeded / (double)terminal.Count * 100, 1); + } + + /// Succeeded / attempts * 100, one decimal — same rounding rule as . + public static double AppInstallSuccessRate(IReadOnlyCollection attempts) + { + if (attempts.Count == 0) return 0; + var succeeded = attempts.Count(a => a.Status == "Succeeded"); + return Math.Round(succeeded / (double)attempts.Count * 100, 1); + } + + /// Durations in minutes, ascending; sessions without a positive duration are left out. + public static List DurationsMinutes(IEnumerable terminal) + => terminal + .Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0) + .Select(s => s.DurationSeconds!.Value / 60.0) + .OrderBy(d => d) + .ToList(); + + public static double P95Minutes(List sortedDurationsMinutes) + => MetricsMath.Percentile(sortedDurationsMinutes, 95); + + /// + /// Install attempts of one ISO week: terminal rows only, skips excluded (a skip is not an + /// attempt and must not pad the rate). + /// + public static List AppInstallAttemptsInWeek(IEnumerable installs, string isoWeekKey) + => installs + .Where(a => (a.Status == "Succeeded" || a.Status == "Failed") + && !MetricsMath.IsSkipTerminalState(a) + && SlaMetricsService.GetIsoWeekKey(a.StartedAt) == isoWeekKey) + .ToList(); + + /// + /// Whether the population changed since the last breach notification. A lasting breach is + /// repeated only when something new finished — a tenant without enrollments must not be told + /// the same number every cooldown. Judged on the finish time, so a session that was still + /// running at the last notification counts once it ends; a row without one falls back to its start. + /// + public static bool HasNewSince(IEnumerable terminal, DateTime lastNotifiedAt) + => terminal.Any(s => (s.CompletedAt ?? s.StartedAt) > lastNotifiedAt); + + public static bool HasNewSince(IEnumerable attempts, DateTime lastNotifiedAt) + => attempts.Any(a => (a.CompletedAt ?? a.StartedAt) > lastNotifiedAt); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs index 61d3f815..8e7dc59b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs @@ -23,6 +23,7 @@ namespace AutopilotMonitor.Functions.Services private readonly IMetricsRepository _metricsRepo; private readonly TenantConfigurationService _configService; private readonly ILogger _logger; + private readonly Func _nowProvider; // Per-tenant cache: key = "tenantId:months" private static readonly ConcurrentDictionary _cache = new(); @@ -32,12 +33,14 @@ namespace AutopilotMonitor.Functions.Services IMaintenanceRepository maintenanceRepo, IMetricsRepository metricsRepo, TenantConfigurationService configService, - ILogger logger) + ILogger logger, + Func? nowProvider = null) { _maintenanceRepo = maintenanceRepo; _metricsRepo = metricsRepo; _configService = configService; _logger = logger; + _nowProvider = nowProvider ?? (() => DateTime.UtcNow); } /// @@ -95,28 +98,39 @@ namespace AutopilotMonitor.Functions.Services private async Task ComputeInternalAsync( string tenantId, int months, Shared.Models.TenantConfiguration? config) { - var now = DateTime.UtcNow; + var now = _nowProvider(); var startDate = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1)); var endDate = now.AddDays(1); - var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(startDate, endDate, tenantId); + // The rolling evaluation window reaches back before the first of the month, which a + // 1-month selection does not cover — read from whichever starts earlier, one read. + var windowStart = SlaEvaluationWindow.WindowStart(now); + var readFrom = windowStart < startDate ? windowStart : startDate; + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(readFrom, endDate, tenantId); // Only consider terminal sessions for SLA computation - var terminal = sessions - .Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed) - .ToList(); + var loadedTerminal = sessions.Where(SlaEvaluationWindow.IsTerminal).ToList(); + // Trend, week cards and violators stay on the selected months — the wider read must + // not grow "1 Month" by the days the evaluation window reaches back. + var terminal = loadedTerminal.Where(s => s.StartedAt >= startDate).ToList(); var response = new SlaMetricsResponse { TargetSuccessRate = config?.SlaTargetSuccessRate, TargetMaxDurationMinutes = config?.SlaTargetMaxDurationMinutes, TargetAppInstallSuccessRate = config?.SlaTargetAppInstallSuccessRate, + EvaluationWindowDays = SlaEvaluationWindow.WindowDays, }; + // Headline: the window the breach notifications evaluate (SlaEvaluationWindow). + response.EvaluationPeriod = BuildSnapshot( + SlaEvaluationWindow.TerminalInWindow(loadedTerminal, now), + SlaEvaluationWindow.PeriodKey, isoWeek: false, config); + // Current week snapshot (ISO 8601 week) var currentWeekKey = GetIsoWeekKey(now); var currentWeekSessions = terminal.Where(s => GetIsoWeekKey(s.StartedAt) == currentWeekKey).ToList(); - response.CurrentWeek = BuildSnapshot(currentWeekSessions, currentWeekKey, config); + response.CurrentWeek = BuildSnapshot(currentWeekSessions, currentWeekKey, isoWeek: true, config); // Weekly trend (newest first) var weekGroups = terminal @@ -140,24 +154,17 @@ namespace AutopilotMonitor.Functions.Services foreach (var group in weekGroups) { var weekSessions = group.ToList(); - var snapshot = BuildSnapshot(weekSessions, group.Key, config); + var snapshot = BuildSnapshot(weekSessions, group.Key, isoWeek: true, config); // App install rate for this week double appInstallRate = 0; bool appInstallMet = true; if (appInstalls != null) { - var weekApps = appInstalls - .Where(a => GetIsoWeekKey(a.StartedAt) == group.Key && - (a.Status == "Succeeded" || a.Status == "Failed") && - // PR0 (2026-07-26): skips are not install attempts — they must - // not pad the SLA app-install success rate. - !Helpers.MetricsMath.IsSkipTerminalState(a)) - .ToList(); + var weekApps = SlaEvaluationWindow.AppInstallAttemptsInWeek(appInstalls, group.Key); if (weekApps.Count >= 5) { - var appSucceeded = weekApps.Count(a => a.Status == "Succeeded"); - appInstallRate = Math.Round((appSucceeded / (double)weekApps.Count) * 100, 1); + appInstallRate = SlaEvaluationWindow.AppInstallSuccessRate(weekApps); appInstallMet = config?.SlaTargetAppInstallSuccessRate == null || appInstallRate >= (double)config.SlaTargetAppInstallSuccessRate; } @@ -179,18 +186,13 @@ namespace AutopilotMonitor.Functions.Services // App install SLA snapshot (current week) if (appInstalls != null) { - var currentWeekApps = appInstalls - .Where(a => GetIsoWeekKey(a.StartedAt) == currentWeekKey && - (a.Status == "Succeeded" || a.Status == "Failed") && - // PR0 (2026-07-26): same convention as the weekly trend above. - !Helpers.MetricsMath.IsSkipTerminalState(a)) - .ToList(); + var currentWeekApps = SlaEvaluationWindow.AppInstallAttemptsInWeek(appInstalls, currentWeekKey); if (currentWeekApps.Count >= 5) { var appSucceeded = currentWeekApps.Count(a => a.Status == "Succeeded"); var appFailed = currentWeekApps.Count(a => a.Status == "Failed"); - var appRate = Math.Round((appSucceeded / (double)currentWeekApps.Count) * 100, 1); + var appRate = SlaEvaluationWindow.AppInstallSuccessRate(currentWeekApps); var topFailing = currentWeekApps .Where(a => a.Status == "Failed") @@ -276,33 +278,30 @@ namespace AutopilotMonitor.Functions.Services } private static SlaSnapshot BuildSnapshot( - List sessions, string weekKey, + List sessions, string periodKey, bool isoWeek, Shared.Models.TenantConfiguration? config) { var total = sessions.Count; var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded); var failed = sessions.Count(s => s.Status == SessionStatus.Failed); - var successRate = total > 0 ? Math.Round((succeeded / (double)total) * 100, 1) : 0; - - var completed = sessions - .Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0) - .ToList(); - var durations = completed - .Select(s => s.DurationSeconds!.Value / 60.0) - .OrderBy(d => d) - .ToList(); + var successRate = SlaEvaluationWindow.SuccessRate(sessions); + var durations = SlaEvaluationWindow.DurationsMinutes(sessions); var avgDuration = durations.Count > 0 ? Math.Round(durations.Average(), 1) : 0; - var p95Duration = MetricsMath.Percentile(durations, 95); + var p95Duration = SlaEvaluationWindow.P95Minutes(durations); var durationTarget = config?.SlaTargetMaxDurationMinutes; var durationViolations = durationTarget.HasValue - ? completed.Count(s => s.DurationSeconds!.Value > durationTarget.Value * 60) + ? durations.Count(d => d > durationTarget.Value) : 0; + // An empty period breaches nothing: with no finished enrollment (or none carrying a + // duration) the rate/P95 are 0 by convention, and 0 must not be judged against a target. return new SlaSnapshot { - Week = weekKey, + Period = periodKey, + Week = isoWeek ? periodKey : "", + HasData = total > 0, TotalCompleted = total, Succeeded = succeeded, Failed = failed, @@ -310,8 +309,10 @@ namespace AutopilotMonitor.Functions.Services AvgDurationMinutes = avgDuration, P95DurationMinutes = p95Duration, DurationViolationCount = durationViolations, - SuccessRateMet = config?.SlaTargetSuccessRate == null || successRate >= (double)config.SlaTargetSuccessRate, - DurationTargetMet = durationTarget == null || p95Duration <= durationTarget.Value, + SuccessRateMet = total == 0 || config?.SlaTargetSuccessRate == null + || successRate >= (double)config.SlaTargetSuccessRate, + DurationTargetMet = durations.Count == 0 || durationTarget == null + || p95Duration <= durationTarget.Value, }; } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index b6e29534..852ddf24 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -4325,6 +4325,10 @@ export interface SlaMetricsResponse { targetSuccessRate?: number; targetMaxDurationMinutes?: number; targetAppInstallSuccessRate?: number; + /** SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number. */ + evaluationPeriod: SlaSnapshot; + /** Length of the rolling evaluation window in days. */ + evaluationWindowDays: number; /** Current ISO week SLA snapshot. */ currentWeek: SlaSnapshot; /** Weekly trend (newest first). */ @@ -4338,10 +4342,14 @@ export interface SlaMetricsResponse { computeDurationMs: number; } -/** SLA compliance snapshot for a single period (ISO week). */ +/** SLA compliance snapshot for a single period (ISO week or the rolling evaluation window). */ export interface SlaSnapshot { - /** ISO week identifier, e.g. "2026-W15". */ + /** Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window. */ + period: string; + /** ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot. */ week: string; + /** False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render "no data". */ + hasData: boolean; totalCompleted: number; succeeded: number; failed: number; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs index 6e053a68..81e8ce8b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs @@ -13,6 +13,16 @@ namespace AutopilotMonitor.Shared.Models.Metrics public int? TargetMaxDurationMinutes { get; set; } public decimal? TargetAppInstallSuccessRate { get; set; } + /// + /// SLA snapshot of the rolling window the success-rate and duration breach notifications + /// evaluate (), so the dashboard headline and the alert + /// show the same number. + /// + public SlaSnapshot EvaluationPeriod { get; set; } = new(); + + /// Length of the rolling evaluation window in days. + public int EvaluationWindowDays { get; set; } + /// Current ISO week SLA snapshot. public SlaSnapshot CurrentWeek { get; set; } = new(); @@ -31,13 +41,22 @@ namespace AutopilotMonitor.Shared.Models.Metrics } /// - /// SLA compliance snapshot for a single period (ISO week). + /// SLA compliance snapshot for a single period (ISO week or the rolling evaluation window). /// public class SlaSnapshot { - /// ISO week identifier, e.g. "2026-W15". + /// Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window. + public string Period { get; set; } = default!; + + /// ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot. public string Week { get; set; } = default!; + /// + /// False when no enrollment finished in the period. The rates are 0 then and both + /// target flags are true — an empty period breaches nothing; clients render "no data". + /// + public bool HasData { get; set; } + public int TotalCompleted { get; set; } public int Succeeded { get; set; } public int Failed { get; set; } diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index b5e305e4..946a6f5f 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -19927,6 +19927,22 @@ }, "optional": true }, + { + "name": "evaluationPeriod", + "type": { + "kind": "ref", + "name": "SlaSnapshot" + }, + "doc": "SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number." + }, + { + "name": "evaluationWindowDays", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Length of the rolling evaluation window in days." + }, { "name": "currentWeek", "type": { @@ -19991,15 +20007,31 @@ }, "SlaSnapshot": { "kind": "object", - "doc": "SLA compliance snapshot for a single period (ISO week).", + "doc": "SLA compliance snapshot for a single period (ISO week or the rolling evaluation window).", "fields": [ + { + "name": "period", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Period identifier: ISO week \"2026-W15\", or \"last-30-days\" for the rolling evaluation window." + }, { "name": "week", "type": { "kind": "primitive", "name": "string" }, - "doc": "ISO week identifier, e.g. \"2026-W15\"." + "doc": "ISO week identifier, e.g. \"2026-W15\". Empty on the evaluation-window snapshot." + }, + { + "name": "hasData", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render \"no data\"." }, { "name": "totalCompleted", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index fc5161fc..9f87edcd 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -4325,6 +4325,10 @@ export interface SlaMetricsResponse { targetSuccessRate?: number; targetMaxDurationMinutes?: number; targetAppInstallSuccessRate?: number; + /** SLA snapshot of the rolling window the success-rate and duration breach notifications evaluate (EvaluationWindowDays), so the dashboard headline and the alert show the same number. */ + evaluationPeriod: SlaSnapshot; + /** Length of the rolling evaluation window in days. */ + evaluationWindowDays: number; /** Current ISO week SLA snapshot. */ currentWeek: SlaSnapshot; /** Weekly trend (newest first). */ @@ -4338,10 +4342,14 @@ export interface SlaMetricsResponse { computeDurationMs: number; } -/** SLA compliance snapshot for a single period (ISO week). */ +/** SLA compliance snapshot for a single period (ISO week or the rolling evaluation window). */ export interface SlaSnapshot { - /** ISO week identifier, e.g. "2026-W15". */ + /** Period identifier: ISO week "2026-W15", or "last-30-days" for the rolling evaluation window. */ + period: string; + /** ISO week identifier, e.g. "2026-W15". Empty on the evaluation-window snapshot. */ week: string; + /** False when no enrollment finished in the period. The rates are 0 then and both target flags are true — an empty period breaches nothing; clients render "no data". */ + hasData: boolean; totalCompleted: number; succeeded: number; failed: number;