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;