feat(notification-outbox): add outbox KPI tiles to Health dashboard

This commit is contained in:
Joseph Doherty
2026-05-19 03:05:41 -04:00
parent 9b05e48ea6
commit 9e7bc7b541
2 changed files with 201 additions and 4 deletions

View File

@@ -4,9 +4,12 @@
@using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring @using ScadaLink.HealthMonitoring
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Communication
@implements IDisposable @implements IDisposable
@inject ICentralHealthAggregator HealthAggregator @inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -17,6 +20,39 @@
</div> </div>
</div> </div>
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
<h6 class="text-muted mb-2">Notification Outbox</h6>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<h3 class="mb-0">@OutboxTileValue(_outboxKpi.QueueDepth)</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "text-warning" : "")">@OutboxTileValue(_outboxKpi.StuckCount)</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "text-danger" : "")">@OutboxTileValue(_outboxKpi.ParkedCount)</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
</div>
@if (!_outboxKpiAvailable && _outboxKpiError != null)
{
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
@if (_siteStates.Count == 0) @if (_siteStates.Count == 0)
{ {
<div class="alert alert-info">No site health reports received yet.</div> <div class="alert alert-info">No site health reports received yet.</div>
@@ -294,6 +330,12 @@
private Timer? _refreshTimer; private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10; private int _autoRefreshSeconds = 10;
// Notification Outbox headline KPIs, refreshed alongside the site states.
private NotificationKpiResponse _outboxKpi =
new(string.Empty, true, null, 0, 0, 0, 0, null);
private bool _outboxKpiAvailable;
private string? _outboxKpiError;
private static bool SiteHasActiveErrors(SiteHealthState state) private static bool SiteHasActiveErrors(SiteHealthState state)
{ {
var report = state.LatestReport; var report = state.LatestReport;
@@ -316,22 +358,53 @@
// Non-fatal — fall back to showing siteId only // Non-fatal — fall back to showing siteId only
} }
RefreshNow(); await RefreshNow();
_refreshTimer = new Timer(_ => _refreshTimer = new Timer(_ =>
{ {
InvokeAsync(() => InvokeAsync(async () =>
{ {
RefreshNow(); await RefreshNow();
StateHasChanged(); StateHasChanged();
}); });
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds)); }, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
} }
private void RefreshNow() private async Task RefreshNow()
{ {
_siteStates = HealthAggregator.GetAllSiteStates(); _siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
} }
private async Task LoadOutboxKpis()
{
try
{
var response = await CommunicationService.GetNotificationKpisAsync(
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_outboxKpi = response;
_outboxKpiAvailable = true;
_outboxKpiError = null;
}
else
{
_outboxKpiAvailable = false;
_outboxKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_outboxKpiAvailable = false;
_outboxKpiError = $"KPI query failed: {ex.Message}";
}
}
// Tiles show the numeric KPI when available, or an em dash when the outbox
// KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) =>
_outboxKpiAvailable ? value.ToString() : "—";
private string GetSiteName(string siteId) private string GetSiteName(string siteId)
{ {
return _siteNames.GetValueOrDefault(siteId, siteId); return _siteNames.GetValueOrDefault(siteId, siteId);

View File

@@ -0,0 +1,124 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Communication;
using ScadaLink.HealthMonitoring;
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
///
/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
/// <see cref="CommunicationService"/> is a concrete class whose outbox calls
/// route through an injected notification-outbox <see cref="IActorRef"/>; the
/// tests reuse the scripted-actor seam established by the Notification Outbox
/// page tests (see <c>NotificationOutboxPageTests</c>).
/// </summary>
public class HealthPageTests : BunitContext
{
private readonly ActorSystem _system = ActorSystem.Create("health-page-tests");
private readonly CommunicationService _comms;
// Mutable scripted reply — individual tests can override before rendering.
private NotificationKpiResponse _kpiReply =
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
public HealthPageTests()
{
_comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
Services.AddSingleton(_comms);
var aggregator = Substitute.For<ICentralHealthAggregator>();
aggregator.GetAllSiteStates()
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo);
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin"),
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void Renders_OutboxKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains("Stuck", cut.Markup);
Assert.Contains("Parked", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">12<", cut.Markup); // QueueDepth
Assert.Contains(">4<", cut.Markup); // StuckCount
Assert.Contains(">3<", cut.Markup); // ParkedCount
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
_kpiReply = new NotificationKpiResponse(
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash.
Assert.Contains("Notification Outbox", cut.Markup);
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
}
base.Dispose(disposing);
}
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to the KPI request
/// with the test's currently-scripted response.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
public ScriptedOutboxActor(HealthPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
}
}
}