Files
scadalink-design/docs/plans/2026-05-19-notifications-nav-group.md

60 KiB

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 <explicit paths>.
  • Build the solution with dotnet build ScadaLink.slnx. TreatWarningsAsErrors is on — zero warnings.
  • Run a single test project with dotnet test tests/<Project>/<Project>.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

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:

namespace ScadaLink.Commons.Types.Notifications;

/// <summary>
/// Point-in-time notification-outbox metrics scoped to a single source site.
/// The per-site counterpart of <see cref="NotificationKpiSnapshot"/>; surfaced
/// in the per-site breakdown table on the Notification KPIs page.
/// </summary>
/// <param name="SourceSiteId">The site identifier these metrics are scoped to.</param>
/// <param name="QueueDepth">Count of this site's non-terminal rows (Pending + Retrying).</param>
/// <param name="StuckCount">
/// Count of this site's non-terminal rows whose <c>CreatedAt</c> is older than the stuck cutoff.
/// </param>
/// <param name="ParkedCount">Count of this site's rows in the Parked status.</param>
/// <param name="DeliveredLastInterval">
/// Count of this site's Delivered rows whose <c>DeliveredAt</c> is at or after the
/// "delivered since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
/// </param>
public record SiteNotificationKpiSnapshot(
    string SourceSiteId,
    int QueueDepth,
    int StuckCount,
    int ParkedCount,
    int DeliveredLastInterval,
    TimeSpan? OldestPendingAge);

Add to INotificationOutboxRepository.cs, immediately after the ComputeKpisAsync method:

    /// <summary>
    /// Computes a point-in-time <see cref="SiteNotificationKpiSnapshot"/> 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 <c>OldestPendingAge</c> is
    /// captured inside the method.
    /// </summary>
    Task<IReadOnlyList<SiteNotificationKpiSnapshot>> 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

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:

[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:

    public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> 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();
    }

    /// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
    private async Task<Dictionary<string, int>> CountBySiteAsync(
        System.Linq.Expressions.Expression<Func<Notification, bool>> 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

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:

[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):

/// <summary>
/// Outbox UI -> Central: request for the per-source-site notification outbox KPI breakdown.
/// </summary>
public record PerSiteNotificationKpiRequest(
    string CorrelationId);

/// <summary>
/// Central -> Outbox UI: per-site KPI breakdown for the Notification KPIs page.
/// On a repository fault <see cref="Success"/> is <c>false</c>, <see cref="ErrorMessage"/>
/// carries the cause, and <see cref="Sites"/> is empty.
/// </summary>
public record PerSiteNotificationKpiResponse(
    string CorrelationId,
    bool Success,
    string? ErrorMessage,
    IReadOnlyList<SiteNotificationKpiSnapshot> 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

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:

[Fact]
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
{
    var repo = Substitute.For<INotificationOutboxRepository>();
    repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
        .Returns(new List<SiteNotificationKpiSnapshot>
        {
            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<PerSiteNotificationKpiResponse>();
    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<INotificationOutboxRepository>();
    repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
        .Returns<IReadOnlyList<SiteNotificationKpiSnapshot>>(_ => throw new InvalidOperationException("db down"));

    var actor = CreateActor(repo);
    actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);

    var response = ExpectMsg<PerSiteNotificationKpiResponse>();
    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<NotificationKpiRequest>(HandleKpiRequest); line:

        Receive<PerSiteNotificationKpiRequest>(HandlePerSiteKpiRequest);

After the ComputeKpisAsync method, add:

    /// <summary>
    /// Handles a per-site KPI request, computing the per-source-site outbox metrics with the
    /// same stuck cutoff and delivered window as <see cref="HandleKpiRequest"/>.
    /// </summary>
    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<SiteNotificationKpiSnapshot>()));
    }

    private async Task<PerSiteNotificationKpiResponse> ComputePerSiteKpisAsync(
        string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
    {
        using var scope = _serviceProvider.CreateScope();
        var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
        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

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 Asks 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:

[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<PerSiteNotificationKpiRequest>(_ => 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:

    public async Task<PerSiteNotificationKpiResponse> GetPerSiteNotificationKpisAsync(
        PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default)
    {
        return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
            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

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.razorsrc/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor
  • Move (if present): Admin/SmtpConfiguration.razor.csNotifications/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:

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:

@page "/admin/smtp"

to:

@page "/notifications/smtp"

Step 3: Update tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs line 30 — change the inline data from:

[InlineData("SMTP Configuration", "/admin/smtp")]

to:

[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

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

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<INotificationRepository>();
        repo.GetAllNotificationListsAsync()
            .Returns(new List<NotificationList> { new("Ops On-Call") { Id = 1 } });
        repo.GetRecipientsByListIdAsync(1)
            .Returns(new List<NotificationRecipient> { new("Jane", "jane@example.com") });
        Services.AddSingleton(repo);
        // wire IDialogService + an authorized Design-role principal — copy
        // NotificationOutboxPageTests' auth/DI setup.

        var cut = Render<NotificationListsPage>();

        Assert.Contains("Ops On-Call", cut.Markup);
    }

    [Fact]
    public void ShowsEmptyState_WhenNoLists()
    {
        var repo = Substitute.For<INotificationRepository>();
        repo.GetAllNotificationListsAsync().Returns(new List<NotificationList>());
        Services.AddSingleton(repo);
        // ...same auth/DI setup...

        var cut = Render<NotificationListsPage>();

        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:

@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

<div class="container-fluid mt-3">
    <ToastNotification @ref="_toast" />

    <div class="d-flex justify-content-between align-items-center mb-3">
        <h4 class="mb-0">Notification Lists</h4>
        <button class="btn btn-primary btn-sm"
                @onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
            Add Notification List
        </button>
    </div>

    @if (_loading)
    {
        <LoadingSpinner IsLoading="true" />
    }
    else if (_errorMessage != null)
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }
    else if (_lists.Count == 0)
    {
        <div class="card">
            <div class="card-body text-center text-muted py-5">
                <div class="fs-5 mb-2">No notification lists</div>
                <button class="btn btn-primary btn-sm"
                        @onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
                    Add your first notification list
                </button>
            </div>
        </div>
    }
    else
    {
        <div class="table-responsive">
            <table class="table table-sm table-hover align-middle">
                <thead class="table-light">
                    <tr>
                        <th>Name</th>
                        <th>Recipients</th>
                        <th class="text-end">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var list in _lists)
                    {
                        var recipients = _recipients.GetValueOrDefault(list.Id) ?? new();
                        <tr @key="list.Id">
                            <td>@list.Name</td>
                            <td>
                                @if (recipients.Count == 0)
                                {
                                    <span class="text-muted small fst-italic">No recipients</span>
                                }
                                else
                                {
                                    @foreach (var r in recipients)
                                    {
                                        <span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@r.EmailAddress&gt;</span>
                                    }
                                }
                            </td>
                            <td class="text-end">
                                <button class="btn btn-outline-primary btn-sm me-1"
                                        @onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
                                    Edit
                                </button>
                                <button class="btn btn-outline-danger btn-sm"
                                        @onclick="() => DeleteList(list)">
                                    Delete
                                </button>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</div>

@code {
    private bool _loading = true;
    private string? _errorMessage;
    private List<NotificationList> _lists = new();
    private readonly Dictionary<int, List<NotificationRecipient>> _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

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:

@page "/design/notification-lists/create"
@page "/design/notification-lists/{Id:int}/edit"

to:

@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 <li> 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

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.csNotificationReportPageTests.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<NotificationOutbox> Logger type argument → ILogger<NotificationReport> (the class name follows the file).
  • Page heading <h4 ...>Notification Outbox</h4><h4 ...>Notification Report</h4>.
  • 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

git rm src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor

Step 3: Migrate the test. Rename the test file and update it:

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

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

// 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<NotificationKpisPage>();

    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<NotificationKpisPage>();
    Assert.Contains("kpi down", cut.Markup);
}

[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
    // script PerSiteNotificationKpiRequest -> Success:true, empty Sites
    var cut = Render<NotificationKpisPage>();
    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:

@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<NotificationKpis> Logger

<div class="container-fluid mt-3">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h4 class="mb-0">Notification KPIs</h4>
        <button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
            @if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
            Refresh
        </button>
    </div>

    @* ── Global KPI tiles ── *@
    @if (_kpiError != null)
    {
        <div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
    }
    else
    {
        <div class="row g-3 mb-4">
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0">@_kpi.QueueDepth</h3>
                        <small class="text-muted">Queue Depth</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100 @(_kpi.StuckCount > 0 ? "border-warning" : "")">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 @(_kpi.StuckCount > 0 ? "text-warning" : "")">@_kpi.StuckCount</h3>
                        <small class="text-muted">Stuck</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100 @(_kpi.ParkedCount > 0 ? "border-danger" : "")">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 @(_kpi.ParkedCount > 0 ? "text-danger" : "")">@_kpi.ParkedCount</h3>
                        <small class="text-muted">Parked</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0 text-success">@_kpi.DeliveredLastInterval</h3>
                        <small class="text-muted">Delivered (last interval)</small>
                    </div>
                </div>
            </div>
            <div class="col-lg col-md-4 col-6">
                <div class="card h-100">
                    <div class="card-body text-center py-3">
                        <h3 class="mb-0">@FormatAge(_kpi.OldestPendingAge)</h3>
                        <small class="text-muted">Oldest Pending Age</small>
                    </div>
                </div>
            </div>
        </div>
    }

    @* ── Per-site breakdown ── *@
    <h5 class="mb-2">Per-site breakdown</h5>
    @if (_perSiteError != null)
    {
        <div class="alert alert-warning py-2">Per-site KPIs unavailable: @_perSiteError</div>
    }
    else if (_perSite.Count == 0)
    {
        <div class="card">
            <div class="card-body text-center text-muted py-4">
                <div class="small">No per-site activity.</div>
            </div>
        </div>
    }
    else
    {
        <div class="table-responsive">
            <table class="table table-sm table-hover align-middle">
                <thead class="table-light">
                    <tr>
                        <th>Site</th>
                        <th class="text-end">Queue Depth</th>
                        <th class="text-end">Stuck</th>
                        <th class="text-end">Parked</th>
                        <th class="text-end">Delivered (last interval)</th>
                        <th class="text-end">Oldest Pending Age</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var s in _perSite)
                    {
                        <tr @key="s.SourceSiteId" class="@(s.StuckCount > 0 ? "table-warning" : "")">
                            <td>@SiteName(s.SourceSiteId)</td>
                            <td class="text-end font-monospace">@s.QueueDepth</td>
                            <td class="text-end font-monospace @(s.StuckCount > 0 ? "text-warning" : "")">@s.StuckCount</td>
                            <td class="text-end font-monospace @(s.ParkedCount > 0 ? "text-danger" : "")">@s.ParkedCount</td>
                            <td class="text-end font-monospace text-success">@s.DeliveredLastInterval</td>
                            <td class="text-end font-monospace">@FormatAge(s.OldestPendingAge)</td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</div>

@code {
    private List<Site> _sites = new();

    private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null);
    private string? _kpiError;

    private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
    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

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 <li> in the Admin section and the Notification Outbox <li> 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).

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<NavMenu>();   // 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<NavMenu>();

        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<NavMenu>();

        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 <li> from the Admin section:

    <li class="nav-item">
        <NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink>
    </li>
    
  2. Remove the Notification Outbox <li> from the Monitoring section:

    <li class="nav-item">
        <NavLink class="nav-link" href="/monitoring/notification-outbox">Notification Outbox</NavLink>
    </li>
    
  3. Insert the Notifications section between the closing of the Deployment AuthorizeView and the Monitoring <div role="presentation" class="nav-section-header">Monitoring</div> line:

    @* 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. *@
    <div role="presentation" class="nav-section-header">Notifications</div>
    <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
        <Authorized Context="notifAdminContext">
            <li class="nav-item">
                <NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
            </li>
        </Authorized>
    </AuthorizeView>
    <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
        <Authorized Context="notifDesignContext">
            <li class="nav-item">
                <NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
            </li>
        </Authorized>
    </AuthorizeView>
    <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
        <Authorized Context="notifDeploymentContext">
            <li class="nav-item">
                <NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
            </li>
            <li class="nav-item">
                <NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
            </li>
        </Authorized>
    </AuthorizeView>
    

    Each <Authorized Context="..."> needs a unique context name (nested AuthorizeViews 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

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"

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 (<h6 ...>Notification Outbox</h6> 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:

[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
    var cut = Render<Health>();   // 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:

<h6 class="text-muted mb-2">Notification Outbox</h6>

with a heading row carrying the link:

<div class="d-flex justify-content-between align-items-center mb-2">
    <h6 class="text-muted mb-0">Notification Outbox</h6>
    <a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>

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

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:

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)

git add <explicit fixed paths>
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.