# Notifications Nav Group Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Consolidate all notification-related Central UI pages under a new **Notifications** left-menu section, split the combined Outbox page into a report page and a KPIs page, give Notification Lists its own page, and add a per-source-site KPI breakdown. **Architecture:** Four pages move to a consistent `/notifications/*` route prefix and a new `Notifications` nav section. The existing `Monitoring/NotificationOutbox.razor` (KPI tiles + filterable table on one page) is split into `NotificationReport.razor` (table) and `NotificationKpis.razor` (tiles + per-site table). The KPI page's per-site breakdown is backed by a bounded, additive backend chain: a new repository method, message contract pair, actor handler, and `CommunicationService` method — each mirroring the existing global-KPI equivalent. **Tech Stack:** .NET 10, Blazor Server + Bootstrap, Akka.NET (cluster singleton actor, TestKit), EF Core (MS SQL; SQLite in-memory for tests), xUnit + NSubstitute + bUnit. **Design doc:** `docs/plans/2026-05-19-notifications-nav-group-design.md` --- ## Conventions for every task - **TDD:** write the failing test first, watch it fail, implement, watch it pass. - **Commit with explicit paths only.** NEVER `git add -A` or `git add .`. There are pre-existing uncommitted files under `infra/` that MUST NOT be committed — always `git add `. - Build the solution with `dotnet build ScadaLink.slnx`. `TreatWarningsAsErrors` is on — zero warnings. - Run a single test project with `dotnet test tests//.csproj`. - Entity/domain POCOs live in `ScadaLink.Commons`; EF fluent configs in `ScadaLink.ConfigurationDatabase`. - Message records follow additive-only evolution — only add new record types, never reorder/remove members of existing ones. --- ## Task 1: Per-site KPI domain type + repository interface method **Files:** - Create: `src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs` - Modify: `src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs` - Test: `tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs` **Step 1: Write the failing test** ```csharp using ScadaLink.Commons.Types.Notifications; namespace ScadaLink.Commons.Tests.Types; public class SiteNotificationKpiSnapshotTests { [Fact] public void Constructor_AssignsAllMembers() { var snapshot = new SiteNotificationKpiSnapshot( SourceSiteId: "plant-a", QueueDepth: 5, StuckCount: 2, ParkedCount: 1, DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(12)); Assert.Equal("plant-a", snapshot.SourceSiteId); Assert.Equal(5, snapshot.QueueDepth); Assert.Equal(2, snapshot.StuckCount); Assert.Equal(1, snapshot.ParkedCount); Assert.Equal(40, snapshot.DeliveredLastInterval); Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge); } [Fact] public void OldestPendingAge_IsNullableForSitesWithNoBacklog() { var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null); Assert.Null(snapshot.OldestPendingAge); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests` Expected: FAIL — `SiteNotificationKpiSnapshot` does not exist. **Step 3: Write minimal implementation** `SiteNotificationKpiSnapshot.cs`: ```csharp namespace ScadaLink.Commons.Types.Notifications; /// /// Point-in-time notification-outbox metrics scoped to a single source site. /// The per-site counterpart of ; surfaced /// in the per-site breakdown table on the Notification KPIs page. /// /// The site identifier these metrics are scoped to. /// Count of this site's non-terminal rows (Pending + Retrying). /// /// Count of this site's non-terminal rows whose CreatedAt is older than the stuck cutoff. /// /// Count of this site's rows in the Parked status. /// /// Count of this site's Delivered rows whose DeliveredAt is at or after the /// "delivered since" timestamp. /// /// /// Age of this site's oldest non-terminal row, or null when it has none. /// public record SiteNotificationKpiSnapshot( string SourceSiteId, int QueueDepth, int StuckCount, int ParkedCount, int DeliveredLastInterval, TimeSpan? OldestPendingAge); ``` Add to `INotificationOutboxRepository.cs`, immediately after the `ComputeKpisAsync` method: ```csharp /// /// Computes a point-in-time per source site. /// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs /// are supplied by the caller; the current time used for OldestPendingAge is /// captured inside the method. /// Task> ComputePerSiteKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default); ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests` Expected: PASS. (`ScadaLink.ConfigurationDatabase` will not yet compile — the interface method is unimplemented. That is fixed in Task 2; do not build the whole solution at this step.) **Step 5: Commit** ```bash git add src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs \ src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs \ tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs git commit -m "feat(notification-outbox): per-site KPI snapshot type + repository contract" ``` --- ## Task 2: ComputePerSiteKpisAsync repository implementation **Files:** - Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs` - Test: `tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs` **Context:** `NotificationOutboxRepository` is an EF Core repository over `ScadaLinkDbContext.Notifications`. The existing global `ComputeKpisAsync` (in the same file) shows the metric definitions and the `DateTimeOffset`-min workaround. The `Notification` entity has a `SourceSiteId` string and a `Status` (`NotificationStatus` enum: `Pending`, `Retrying`, `Delivered`, `Parked`, `Discarded`). Look at an existing repository test in `tests/ScadaLink.ConfigurationDatabase.Tests/` to copy the SQLite-in-memory `ScadaLinkDbContext` setup pattern. **Step 1: Write the failing test** Mirror the existing repository-test setup (in-memory SQLite `ScadaLinkDbContext`). Seed `Notification` rows across two sites and assert the per-site aggregation: ```csharp [Fact] public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite() { await using var ctx = NewContext(); // copy the in-memory-SQLite helper used by sibling tests var now = DateTimeOffset.UtcNow; // plant-a: 1 pending (stuck, created 20m ago), 1 parked ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, createdAt: now.AddMinutes(-20))); ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, createdAt: now.AddMinutes(-5))); // plant-b: 1 delivered in-window, 1 pending (fresh) ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2))); ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, createdAt: now.AddMinutes(-1))); await ctx.SaveChangesAsync(); var repo = new NotificationOutboxRepository(ctx); var result = await repo.ComputePerSiteKpisAsync( stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30)); var a = result.Single(s => s.SourceSiteId == "plant-a"); Assert.Equal(1, a.QueueDepth); Assert.Equal(1, a.StuckCount); Assert.Equal(1, a.ParkedCount); Assert.Equal(0, a.DeliveredLastInterval); Assert.NotNull(a.OldestPendingAge); var b = result.Single(s => s.SourceSiteId == "plant-b"); Assert.Equal(1, b.QueueDepth); Assert.Equal(0, b.StuckCount); Assert.Equal(1, b.DeliveredLastInterval); } [Fact] public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications() { await using var ctx = NewContext(); var repo = new NotificationOutboxRepository(ctx); var result = await repo.ComputePerSiteKpisAsync( DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30)); Assert.Empty(result); } ``` Define the `NewContext()` and `NewNotification(...)` helpers by copying the conventions from the nearest existing `NotificationOutboxRepository` test (check `RepositoryCoverageTests.cs` for how `Notification` rows are constructed). **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests` Expected: FAIL — `ComputePerSiteKpisAsync` not implemented. **Step 3: Write minimal implementation** Add to `NotificationOutboxRepository.cs`, after `ComputeKpisAsync`: ```csharp public async Task> ComputePerSiteKpisAsync( DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var queueDepth = await CountBySiteAsync( n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying, cancellationToken); var stuck = await CountBySiteAsync( n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) && n.CreatedAt < stuckCutoff, cancellationToken); var parked = await CountBySiteAsync( n => n.Status == NotificationStatus.Parked, cancellationToken); var delivered = await CountBySiteAsync( n => n.Status == NotificationStatus.Delivered && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince, cancellationToken); // Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset // converter is awkward (see ComputeKpisAsync), so project the non-terminal // (site, created) pairs — the live queue, which stays bounded — and reduce // in memory. var oldest = (await _context.Notifications .Where(n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying) .Select(n => new { n.SourceSiteId, n.CreatedAt }) .ToListAsync(cancellationToken)) .GroupBy(x => x.SourceSiteId) .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt)); var siteIds = queueDepth.Keys .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys) .Distinct() .OrderBy(s => s, StringComparer.Ordinal); return siteIds.Select(site => new SiteNotificationKpiSnapshot( SourceSiteId: site, QueueDepth: queueDepth.GetValueOrDefault(site), StuckCount: stuck.GetValueOrDefault(site), ParkedCount: parked.GetValueOrDefault(site), DeliveredLastInterval: delivered.GetValueOrDefault(site), OldestPendingAge: oldest.TryGetValue(site, out var createdAt) ? now - createdAt : null)).ToList(); } /// Counts notification rows matching , grouped by source site. private async Task> CountBySiteAsync( System.Linq.Expressions.Expression> predicate, CancellationToken cancellationToken) { return await _context.Notifications .Where(predicate) .GroupBy(n => n.SourceSiteId) .Select(g => new { Site = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken); } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs \ tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs git commit -m "feat(notification-outbox): per-site KPI aggregation in the repository" ``` --- ## Task 3: Per-site KPI message contracts **Files:** - Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs` - Test: `tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs` (add cases) **Context:** `NotificationOutboxQueries.cs` already holds `NotificationKpiRequest` / `NotificationKpiResponse`. The per-site response carries the `SiteNotificationKpiSnapshot` records directly (no flattening — the type is a clean serializable record in Commons). Additive-only: append new records, do not touch existing ones. **Step 1: Write the failing test** Add to `NotificationMessagesTests.cs`: ```csharp [Fact] public void PerSiteNotificationKpiRequest_CarriesCorrelationId() { var request = new PerSiteNotificationKpiRequest("corr-1"); Assert.Equal("corr-1", request.CorrelationId); } [Fact] public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots() { var sites = new[] { new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)), }; var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites); Assert.True(response.Success); Assert.Null(response.ErrorMessage); Assert.Single(response.Sites); Assert.Equal("plant-a", response.Sites[0].SourceSiteId); } ``` Add `using ScadaLink.Commons.Types.Notifications;` to the test file if not present. **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests` Expected: FAIL — the two records do not exist. **Step 3: Write minimal implementation** Append to `NotificationOutboxQueries.cs` (add `using ScadaLink.Commons.Types.Notifications;` at the top if absent): ```csharp /// /// Outbox UI -> Central: request for the per-source-site notification outbox KPI breakdown. /// public record PerSiteNotificationKpiRequest( string CorrelationId); /// /// Central -> Outbox UI: per-site KPI breakdown for the Notification KPIs page. /// On a repository fault is false, /// carries the cause, and is empty. /// public record PerSiteNotificationKpiResponse( string CorrelationId, bool Success, string? ErrorMessage, IReadOnlyList Sites); ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs \ tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs git commit -m "feat(notification-outbox): per-site KPI request/response message contracts" ``` --- ## Task 4: NotificationOutboxActor per-site KPI handler **Files:** - Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` - Test: `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs` (add cases) **Context:** The actor registers handlers in its constructor and resolves `INotificationOutboxRepository` per-request from a fresh DI scope (`_serviceProvider.CreateScope()`) to avoid a captive dependency. `HandleKpiRequest` / `ComputeKpisAsync` are the exact template to mirror. `StuckCutoff(now)` and `_options.DeliveredKpiWindow` already exist. `NotificationOutboxActorQueryTests.cs` shows how the actor is spun up with a substituted repository. **Step 1: Write the failing test** Add to `NotificationOutboxActorQueryTests.cs`, following the existing KPI-request test in that file: ```csharp [Fact] public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots() { var repo = Substitute.For(); repo.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new List { new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)), }); var actor = CreateActor(repo); // reuse the helper this test class already uses actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor); var response = ExpectMsg(); Assert.True(response.Success); Assert.Equal("corr-ps", response.CorrelationId); Assert.Single(response.Sites); Assert.Equal("plant-a", response.Sites[0].SourceSiteId); } [Fact] public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful() { var repo = Substitute.For(); repo.ComputePerSiteKpisAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns>(_ => throw new InvalidOperationException("db down")); var actor = CreateActor(repo); actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor); var response = ExpectMsg(); Assert.False(response.Success); Assert.Contains("db down", response.ErrorMessage); Assert.Empty(response.Sites); } ``` Match the seam the existing KPI test uses (the `CreateActor` helper name may differ — copy whatever the sibling KPI test does). **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests` Expected: FAIL — `PerSiteNotificationKpiRequest` is unhandled (TestKit reports an unexpected message / no reply). **Step 3: Write minimal implementation** In the constructor of `NotificationOutboxActor`, after the `Receive(HandleKpiRequest);` line: ```csharp Receive(HandlePerSiteKpiRequest); ``` After the `ComputeKpisAsync` method, add: ```csharp /// /// Handles a per-site KPI request, computing the per-source-site outbox metrics with the /// same stuck cutoff and delivered window as . /// private void HandlePerSiteKpiRequest(PerSiteNotificationKpiRequest request) { var sender = Sender; var now = DateTimeOffset.UtcNow; var stuckCutoff = StuckCutoff(now); var deliveredSince = now - _options.DeliveredKpiWindow; ComputePerSiteKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo( sender, success: response => response, failure: ex => new PerSiteNotificationKpiResponse( request.CorrelationId, Success: false, ErrorMessage: ex.GetBaseException().Message, Sites: Array.Empty())); } private async Task ComputePerSiteKpisAsync( string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince) { using var scope = _serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var sites = await repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince); return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites); } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs \ tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs git commit -m "feat(notification-outbox): actor handler for per-site KPI requests" ``` --- ## Task 5: CommunicationService.GetPerSiteNotificationKpisAsync **Files:** - Modify: `src/ScadaLink.Communication/CommunicationService.cs` - Test: `tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs` (add a case) **Context:** `CommunicationService` has a "Notification Outbox" region. `GetNotificationKpisAsync` is the exact template — it `Ask`s the notification-outbox actor proxy (`GetNotificationOutbox()`) with `_options.QueryTimeout`. The test class wires a real lightweight `ActorSystem` with a scripted actor (the same `SetNotificationOutbox` seam the outbox page tests use). **Step 1: Write the failing test** Add to `CommunicationServiceTests.cs`, mirroring the existing `GetNotificationKpisAsync` test: ```csharp [Fact] public async Task GetPerSiteNotificationKpisAsync_RoundTripsThroughTheOutboxActor() { var expected = new PerSiteNotificationKpiResponse("corr-ps", true, null, new[] { new SiteNotificationKpiSnapshot("plant-a", 2, 0, 0, 5, null) }); // Scripted actor replies with `expected` to any PerSiteNotificationKpiRequest — // copy the scripted-actor pattern used by the sibling KPI test. var service = CreateServiceWithScriptedOutbox(_ => expected); var actual = await service.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest("corr-ps")); Assert.True(actual.Success); Assert.Single(actual.Sites); Assert.Equal("plant-a", actual.Sites[0].SourceSiteId); } ``` If the test class uses an inline scripted `ReceiveActor` rather than a `CreateServiceWith...` helper, follow that existing shape instead — the point is a scripted reply to `PerSiteNotificationKpiRequest`. **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync` Expected: FAIL — `GetPerSiteNotificationKpisAsync` does not exist. **Step 3: Write minimal implementation** In `CommunicationService.cs`, in the Notification Outbox region, after `GetNotificationKpisAsync`: ```csharp public async Task GetPerSiteNotificationKpisAsync( PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default) { return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync` Expected: PASS. **Step 5: Build the whole solution** to confirm the backend chain is clean. Run: `dotnet build ScadaLink.slnx` Expected: Build succeeded, 0 warnings. **Step 6: Commit** ```bash git add src/ScadaLink.Communication/CommunicationService.cs \ tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs git commit -m "feat(notification-outbox): CommunicationService per-site KPI accessor" ``` --- ## Task 6: Move SMTP Configuration page to /notifications/smtp **Files:** - Move: `src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor` → `src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor` - Move (if present): `Admin/SmtpConfiguration.razor.cs` → `Notifications/SmtpConfiguration.razor.cs` - Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs:30` **Context:** The page currently declares `@page "/admin/smtp"` and lives in the `Admin` Pages folder. Only the route and folder/namespace change — content and `RequireAdmin` policy stay. The Razor namespace follows the folder, so a moved `.razor` resolves to `ScadaLink.CentralUI.Components.Pages.Notifications`. There is no test that renders this page directly; the only reference is the Playwright `NavigationTests` inline data. **Step 1:** Create the `Components/Pages/Notifications/` folder by writing the moved file there. `git mv` the file: ```bash git mv src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \ src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor ``` If a code-behind `SmtpConfiguration.razor.cs` exists, `git mv` it too and update its `namespace` to `ScadaLink.CentralUI.Components.Pages.Notifications`. **Step 2:** In the moved `Notifications/SmtpConfiguration.razor`, change line 1 from: ```razor @page "/admin/smtp" ``` to: ```razor @page "/notifications/smtp" ``` **Step 3:** Update `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs` line 30 — change the inline data from: ```csharp [InlineData("SMTP Configuration", "/admin/smtp")] ``` to: ```csharp [InlineData("SMTP Configuration", "/notifications/smtp")] ``` **Step 4: Build to verify** Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` Expected: Build succeeded, 0 warnings. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \ src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor \ tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs git commit -m "refactor(central-ui): move SMTP Configuration page to /notifications/smtp" ``` (Include the code-behind paths in `git add` if one was moved.) --- ## Task 7: New Notification Lists page **Files:** - Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor` - Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs` **Context:** Notification lists are currently shown only inside the "Notification Lists" tab of `Components/Pages/Design/ExternalSystems.razor` (the `RenderNotificationLists` render fragment, lines ~301-380). This task creates a standalone page; Task 8 removes that tab. `INotificationRepository` exposes `GetAllNotificationListsAsync()`, `GetRecipientsByListIdAsync(int)`, `DeleteNotificationListAsync(int)`, `SaveChangesAsync()`. `NotificationList` has `Id` and `Name`; `NotificationRecipient` has `Name` and `EmailAddress`. The page is `RequireDesign`. Look at `NotificationOutboxPageTests.cs` for the bUnit `AuthorizeView` + DI wiring pattern (substituted repositories, a `TestAuthorizationContext`-style claims principal). **Step 1: Write the failing test** ```csharp using Bunit; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Interfaces.Repositories; using NotificationListsPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationLists; namespace ScadaLink.CentralUI.Tests.Pages; public class NotificationListsPageTests : BunitContext { [Fact] public void RendersNotificationListRows() { var repo = Substitute.For(); repo.GetAllNotificationListsAsync() .Returns(new List { new("Ops On-Call") { Id = 1 } }); repo.GetRecipientsByListIdAsync(1) .Returns(new List { new("Jane", "jane@example.com") }); Services.AddSingleton(repo); // wire IDialogService + an authorized Design-role principal — copy // NotificationOutboxPageTests' auth/DI setup. var cut = Render(); Assert.Contains("Ops On-Call", cut.Markup); } [Fact] public void ShowsEmptyState_WhenNoLists() { var repo = Substitute.For(); repo.GetAllNotificationListsAsync().Returns(new List()); Services.AddSingleton(repo); // ...same auth/DI setup... var cut = Render(); Assert.Contains("No notification lists", cut.Markup); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests` Expected: FAIL — `NotificationLists` page does not exist. **Step 3: Write minimal implementation** `NotificationLists.razor`: ```razor @page "/notifications/lists" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Notifications @using ScadaLink.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject INotificationRepository NotificationRepository @inject NavigationManager NavigationManager @inject IDialogService Dialog

Notification Lists

@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else if (_lists.Count == 0) {
No notification lists
} else {
@foreach (var list in _lists) { var recipients = _recipients.GetValueOrDefault(list.Id) ?? new(); }
Name Recipients Actions
@list.Name @if (recipients.Count == 0) { No recipients } else { @foreach (var r in recipients) { @r.Name <@r.EmailAddress> } }
}
@code { private bool _loading = true; private string? _errorMessage; private List _lists = new(); private readonly Dictionary> _recipients = new(); private ToastNotification _toast = default!; protected override async Task OnInitializedAsync() => await LoadAsync(); private async Task LoadAsync() { _loading = true; _errorMessage = null; try { _lists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList(); _recipients.Clear(); foreach (var list in _lists) { var recips = await NotificationRepository.GetRecipientsByListIdAsync(list.Id); _recipients[list.Id] = recips.ToList(); } } catch (Exception ex) { _errorMessage = ex.Message; } _loading = false; } private async Task DeleteList(NotificationList list) { if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true)) { return; } try { await NotificationRepository.DeleteNotificationListAsync(list.Id); await NotificationRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAsync(); } catch (Exception ex) { _toast.ShowError(ex.Message); } } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor \ tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs git commit -m "feat(central-ui): standalone Notification Lists page" ``` --- ## Task 8: Move the Notification List form route; drop the External Systems tab **Files:** - Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor` - Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor` **Context:** `NotificationListForm.razor` has two `@page` routes under `/design/notification-lists/...` and navigates back to `/design/external-systems` (`GoBack()` and the post-`Save()` redirect). `ExternalSystems.razor` is a 4-tab page ("Integration Definitions"): External Systems, Database Connections, **Notification Lists**, Inbound API Methods. Removing the Notification Lists tab leaves three. This task has no behavioral test of its own (Task 7 covers the new lists page); verify by build + a render-smoke check. **Step 1: Re-point the form routes.** In `NotificationListForm.razor`, change lines 1-2: ```razor @page "/design/notification-lists/create" @page "/design/notification-lists/{Id:int}/edit" ``` to: ```razor @page "/notifications/lists/create" @page "/notifications/lists/{Id:int}/edit" ``` **Step 2: Re-point the form's back-navigation.** In `NotificationListForm.razor`: - In `Save()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");` - In `GoBack()`, change `NavigationManager.NavigateTo("/design/external-systems");` to `NavigationManager.NavigateTo("/notifications/lists");` **Step 3: Remove the Notification Lists tab from `ExternalSystems.razor`.** Delete, from that file: - The tab `
  • ` button (the `_tab == "notif"` nav-item, ~lines 50-58). - The tab-panel branch `else if (_tab == "notif") { ... }` (~lines 78-81). - The `RenderNotificationLists()` render fragment method (~lines 301-373). - The `DeleteNotifList(...)` method (~lines 375-380). - The Notification Lists `@code` fields: `_notificationLists`, `_recipients`, `_notifSearch`, `FilteredNotificationLists` (~lines 110-117). - In `LoadAllAsync()`, the lines that populate `_notificationLists` and the `_recipients` loop (~lines 141-148). - The now-unused `@inject INotificationRepository NotificationRepository` (line 9) and `@using ScadaLink.Commons.Entities.Notifications` (line 4) — remove only if nothing else in the file still uses them (verify by search). **Step 4: Build to verify** Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` Expected: Build succeeded, 0 warnings. (A warning here usually means a leftover unused `using`/`@inject` — remove it.) **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor \ src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor git commit -m "refactor(central-ui): move Notification List form to /notifications, drop External Systems tab" ``` --- ## Task 9: New Notification Report page; retire the Outbox page **Files:** - Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor` - Delete: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor` - Rename: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs` → `NotificationReportPageTests.cs` **Context:** `Monitoring/NotificationOutbox.razor` currently combines KPI tiles (markup lines 23-72) with a filterable, paginated notifications table + Retry/Discard (lines 74-253). The new `NotificationReport.razor` keeps everything **except** the KPI tiles. Its existing test, `NotificationOutboxPageTests.cs`, renders the page via a scripted Akka actor seam and asserts on both KPI tiles and table rows. **Step 1: Create `NotificationReport.razor`** as a copy of `Monitoring/NotificationOutbox.razor` with these changes: - Line 1 route: `@page "/monitoring/notification-outbox"` → `@page "/notifications/report"`. - The `@inject ILogger Logger` type argument → `ILogger` (the class name follows the file). - Page heading `

    Notification Outbox

    ` → `

    Notification Report

    `. - **Delete the KPI tiles markup block** — the `@* ── KPI tiles ── *@` comment through the closing of its `else { ... }` (lines 23-72). - **Delete the KPI `@code` members:** `_kpi`, `_kpiError`, and the `LoadKpis()` method. - **Simplify `RefreshAll()`** — it currently does `await Task.WhenAll(LoadKpis(), FetchPage());`. Replace its body with `await FetchPage();` (keep the method name so the Retry/Discard callers are untouched), or inline `FetchPage()` at the three call sites and delete `RefreshAll()`. Keep `FormatAge` only if still referenced; if the KPI removal orphans it, delete it (build will flag it under `TreatWarningsAsErrors`). **Step 2: Delete the old page** ```bash git rm src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor ``` **Step 3: Migrate the test.** Rename the test file and update it: ```bash git mv tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \ tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs ``` In `NotificationReportPageTests.cs`: - Rename the class to `NotificationReportPageTests`. - Change the page alias: `using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;` and update render calls. - **Delete the KPI-tile assertions** (any test asserting on Queue Depth / Stuck / Parked tile values, and the `_kpiReply` field if now unused — but `_queryReply`, retry/discard scripting stay). - Keep the table-rendering, filter, pagination, and Retry/Discard tests. **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationReportPageTests` Expected: PASS. Also build the UI project to confirm no dangling references to the deleted page: Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj` Expected: Build succeeded, 0 warnings. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor \ src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor \ tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \ tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs git commit -m "refactor(central-ui): split Notification Report out of the Outbox page" ``` --- ## Task 10: New Notification KPIs page **Files:** - Create: `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor` - Test: `tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs` **Context:** This page shows the five global KPI tiles (lifted from the old Outbox page, markup lines 30-71) plus a per-source-site breakdown table backed by `CommunicationService.GetPerSiteNotificationKpisAsync` (Task 5). `CommunicationService` is a concrete class with non-virtual methods, so tests drive it through the scripted-actor seam — copy the `ActorSystem` + scripted `ReceiveActor` + `SetNotificationOutbox` setup verbatim from `NotificationReportPageTests.cs` (Task 9). The scripted actor must answer **both** `NotificationKpiRequest` and `PerSiteNotificationKpiRequest`. Site friendly-names come from `ISiteRepository.GetAllSitesAsync()` (`Site.SiteIdentifier` / `Site.Name`), exactly as the old Outbox page resolved the source-site filter. **Step 1: Write the failing test** ```csharp // Copy the ActorSystem / scripted-actor / CommunicationService / ISiteRepository / auth // wiring from NotificationReportPageTests. Script the actor to reply: // NotificationKpiRequest -> a NotificationKpiResponse with known tile values // PerSiteNotificationKpiRequest -> a PerSiteNotificationKpiResponse with one site row [Fact] public void RendersGlobalTilesAndPerSiteRows() { // _kpiReply: QueueDepth 7, StuckCount 2, ParkedCount 1, DeliveredLastInterval 42 // _perSiteReply: [ SiteNotificationKpiSnapshot("plant-a", 4, 1, 0, 9, 7m) ] var cut = Render(); Assert.Contains("Queue Depth", cut.Markup); Assert.Contains("7", cut.Markup); // global tile value Assert.Contains("plant-a", cut.Markup); // per-site row } [Fact] public void ShowsKpiError_WhenGlobalKpiQueryFails() { // script NotificationKpiRequest -> response with Success:false, ErrorMessage:"kpi down" var cut = Render(); Assert.Contains("kpi down", cut.Markup); } [Fact] public void ShowsPerSiteEmptyState_WhenNoSites() { // script PerSiteNotificationKpiRequest -> Success:true, empty Sites var cut = Render(); Assert.Contains("No per-site activity", cut.Markup); } ``` Use `using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis;`. **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests` Expected: FAIL — `NotificationKpis` page does not exist. **Step 3: Write minimal implementation** `NotificationKpis.razor`: ```razor @page "/notifications/kpis" @attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Messages.Notification @using ScadaLink.Commons.Types.Notifications @using ScadaLink.Communication @inject CommunicationService CommunicationService @inject ISiteRepository SiteRepository @inject ILogger Logger

    Notification KPIs

    @* ── Global KPI tiles ── *@ @if (_kpiError != null) {
    KPIs unavailable: @_kpiError
    } else {

    @_kpi.QueueDepth

    Queue Depth

    @_kpi.StuckCount

    Stuck

    @_kpi.ParkedCount

    Parked

    @_kpi.DeliveredLastInterval

    Delivered (last interval)

    @FormatAge(_kpi.OldestPendingAge)

    Oldest Pending Age
    } @* ── Per-site breakdown ── *@
    Per-site breakdown
    @if (_perSiteError != null) {
    Per-site KPIs unavailable: @_perSiteError
    } else if (_perSite.Count == 0) {
    No per-site activity.
    } else {
    @foreach (var s in _perSite) { }
    Site Queue Depth Stuck Parked Delivered (last interval) Oldest Pending Age
    @SiteName(s.SourceSiteId) @s.QueueDepth @s.StuckCount @s.ParkedCount @s.DeliveredLastInterval @FormatAge(s.OldestPendingAge)
    }
    @code { private List _sites = new(); private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null); private string? _kpiError; private IReadOnlyList _perSite = Array.Empty(); private string? _perSiteError; private bool _loading; protected override async Task OnInitializedAsync() { try { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); } catch (Exception ex) { // Non-fatal — the per-site table falls back to raw site identifiers. Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown."); } await RefreshAll(); } private async Task RefreshAll() { _loading = true; // Race-free despite both tasks mutating component fields: Blazor Server runs // every continuation on the circuit's single-threaded synchronization context. await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis()); _loading = false; } private async Task LoadGlobalKpis() { try { var response = await CommunicationService.GetNotificationKpisAsync( new NotificationKpiRequest(Guid.NewGuid().ToString("N"))); if (response.Success) { _kpi = response; _kpiError = null; } else { _kpiError = response.ErrorMessage ?? "KPI query failed."; } } catch (Exception ex) { _kpiError = $"KPI query failed: {ex.Message}"; } } private async Task LoadPerSiteKpis() { try { var response = await CommunicationService.GetPerSiteNotificationKpisAsync( new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N"))); if (response.Success) { _perSite = response.Sites; _perSiteError = null; } else { _perSiteError = response.ErrorMessage ?? "Per-site KPI query failed."; } } catch (Exception ex) { _perSiteError = $"Per-site KPI query failed: {ex.Message}"; } } private string SiteName(string siteId) => _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; private static string FormatAge(TimeSpan? age) { if (age == null) return "—"; var t = age.Value; if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s"; if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m"; if (t.TotalHours < 24) return $"{(int)t.TotalHours}h"; return $"{(int)t.TotalDays}d"; } } ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor \ tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs git commit -m "feat(central-ui): Notification KPIs page with per-site breakdown" ``` --- ## Task 11: NavMenu — add the Notifications section **Files:** - Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor` - Test: `tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs` (create) **Context:** `NavMenu.razor` builds the sidebar. It has gated sections (Admin / Design / Deployment each wrapped in one `AuthorizeView Policy=...`) and a Monitoring section with per-item gating. Two existing items must move out: the **SMTP Configuration** `
  • ` in the Admin section and the **Notification Outbox** `
  • ` in the Monitoring section. The new Notifications section goes **between the Deployment section and the Monitoring section**. Each item is wrapped in its own per-item `AuthorizeView` (the Monitoring section already does this — copy that shape). The plain-`div` section header needs no gating: every authenticated user holds at least one of Admin/Design/Deployment. **Step 1: Write the failing test** bUnit-render `NavMenu` under different role principals. Copy the authorization wiring from an existing nav/auth test (e.g. `Monitoring/MonitoringAuthorizationTests.cs` or `NotificationReportPageTests`). ```csharp using Bunit; using ScadaLink.Security; using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; namespace ScadaLink.CentralUI.Tests.Layout; public class NavMenuTests : BunitContext { [Fact] public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() { // authorize a principal holding Admin + Design + Deployment roles var cut = Render(); // with the authorized principal wired in Assert.Contains("Notifications", cut.Markup); Assert.Contains("/notifications/smtp", cut.Markup); Assert.Contains("/notifications/lists", cut.Markup); Assert.Contains("/notifications/report", cut.Markup); Assert.Contains("/notifications/kpis", cut.Markup); } [Fact] public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() { // authorize a principal holding only the Admin role var cut = Render(); Assert.Contains("/notifications/smtp", cut.Markup); Assert.DoesNotContain("/notifications/report", cut.Markup); Assert.DoesNotContain("/notifications/lists", cut.Markup); } [Fact] public void OldRoutes_AreNoLongerLinked() { // authorize a multi-role principal var cut = Render(); Assert.DoesNotContain("/admin/smtp", cut.Markup); Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup); } } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests` Expected: FAIL — old routes still present, new section absent. **Step 3: Write minimal implementation** In `NavMenu.razor`: 1. **Remove** the SMTP `
  • ` from the Admin section: ```razor
  • ``` 2. **Remove** the Notification Outbox `
  • ` from the Monitoring section: ```razor
  • ``` 3. **Insert the Notifications section** between the closing of the Deployment `AuthorizeView` and the Monitoring `` line: ```razor @* Notifications — mixed-role section; each item gated by its own policy. The header is ungated: every authenticated user holds at least one of Admin/Design/Deployment, so it always has a visible child. *@ ``` Each `` needs a unique context name (nested `AuthorizeView`s require it — the file already uses this pattern). **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor \ tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs git commit -m "feat(central-ui): add the Notifications nav section" ``` --- ## Task 12: Health dashboard — link to the KPI page **Files:** - Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor` - Test: `tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs` (add a case) **Context:** `Health.razor` keeps a "Notification Outbox" headline tile row (`
    Notification Outbox
    ` at line ~24, three tiles below). Add a "View details →" link to `/notifications/kpis` next to that heading. The tiles and their data path are unchanged. **Step 1: Write the failing test** Add to `HealthPageTests.cs`, following its existing render setup: ```csharp [Fact] public void RendersLinkToTheNotificationKpisPage() { var cut = Render(); // match the existing HealthPageTests render call Assert.Contains("/notifications/kpis", cut.Markup); } ``` **Step 2: Run test to verify it fails** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter RendersLinkToTheNotificationKpisPage` Expected: FAIL — no such link. **Step 3: Write minimal implementation** In `Health.razor`, replace the bare heading line: ```razor
    Notification Outbox
    ``` with a heading row carrying the link: ```razor
    Notification Outbox
    View details →
    ``` **Step 4: Run test to verify it passes** Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter HealthPageTests` Expected: PASS. **Step 5: Commit** ```bash git add src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor \ tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs git commit -m "feat(central-ui): link Health outbox tiles to the Notification KPIs page" ``` --- ## Task 13: Full build + suite verification **Files:** none (verification only). **Step 1: Clean build** Run: `dotnet build ScadaLink.slnx` Expected: Build succeeded, **0 warnings** (`TreatWarningsAsErrors` is on). **Step 2: Full test suite** Run: `dotnet test ScadaLink.slnx` Expected: all test projects green. If `SandboxTests.Sandbox_LongRunningScript_TimesOut` or an `InstanceActorChildAttributeRace` test fails, that is a known pre-existing CPU-timing flake under parallel load — re-run `tests/ScadaLink.SiteRuntime.Tests` in isolation to confirm it is not a regression from this work. **Step 3: Grep for stale references** Run: ```bash grep -rn "monitoring/notification-outbox\|admin/smtp\|design/notification-lists" \ src tests --include="*.razor" --include="*.cs" | grep -v "/obj/" | grep -v "/bin/" ``` Expected: **no matches.** Any hit is a stale link to fix before finishing. **Step 4: Commit (only if Step 3 required fixes)** ```bash git add git commit -m "fix(central-ui): clear stale notification route references" ``` If no fixes were needed, there is nothing to commit — the work is complete. --- ## Follow-ups (not in scope, track separately) - Per-site KPI **trend** charts would need a time-series store — deliberately omitted (no historical persistence; KPIs are point-in-time). - The `Notification Outbox` **component** name (#21 in CLAUDE.md) is unchanged; only UI page names changed. If the component is ever renamed, update CLAUDE.md and `README.md` together.