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

1447 lines
60 KiB
Markdown

# 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**
```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;
/// <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:
```csharp
/// <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**
```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<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**
```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
/// <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**
```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<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:
```csharp
Receive<PerSiteNotificationKpiRequest>(HandlePerSiteKpiRequest);
```
After the `ComputeKpisAsync` method, add:
```csharp
/// <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**
```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<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`:
```csharp
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**
```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<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`:
```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**
```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 `<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**
```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<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**
```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<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`:
```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**
```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** `<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`).
```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<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:
```razor
<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:
```razor
<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:
```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. *@
<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 `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 (`<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:
```csharp
[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:
```razor
<h6 class="text-muted mb-2">Notification Outbox</h6>
```
with a heading row carrying the link:
```razor
<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**
```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 <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.