1447 lines
60 KiB
Markdown
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 <@r.EmailAddress></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 →</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.
|