diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor
index 4d829cc..bc3d123 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor
@@ -4,9 +4,12 @@
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring
+@using ScadaLink.Commons.Messages.Notification
+@using ScadaLink.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
+@inject CommunicationService CommunicationService
+ @* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
+ Notification Outbox
+
+
+
+
+
@OutboxTileValue(_outboxKpi.QueueDepth)
+ Queue Depth
+
+
+
+
+
+
+
@OutboxTileValue(_outboxKpi.StuckCount)
+ Stuck
+
+
+
+
+
+
+
@OutboxTileValue(_outboxKpi.ParkedCount)
+ Parked
+
+
+
+
+ @if (!_outboxKpiAvailable && _outboxKpiError != null)
+ {
+ Notification Outbox KPIs unavailable: @_outboxKpiError
+ }
+
@if (_siteStates.Count == 0)
{
No site health reports received yet.
@@ -294,6 +330,12 @@
private Timer? _refreshTimer;
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)
{
var report = state.LatestReport;
@@ -316,22 +358,53 @@
// Non-fatal — fall back to showing siteId only
}
- RefreshNow();
+ await RefreshNow();
_refreshTimer = new Timer(_ =>
{
- InvokeAsync(() =>
+ InvokeAsync(async () =>
{
- RefreshNow();
+ await RefreshNow();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
- private void RefreshNow()
+ private async Task RefreshNow()
{
_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)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
new file mode 100644
index 0000000..0d6eb13
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
@@ -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;
+
+///
+/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
+///
+/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
+/// is an interface (mockable), but
+/// is a concrete class whose outbox calls
+/// route through an injected notification-outbox ; the
+/// tests reuse the scripted-actor seam established by the Notification Outbox
+/// page tests (see NotificationOutboxPageTests).
+///
+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.Instance);
+
+ var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
+ _comms.SetNotificationOutbox(outbox);
+ Services.AddSingleton(_comms);
+
+ var aggregator = Substitute.For();
+ aggregator.GetAllSiteStates()
+ .Returns(new Dictionary());
+ Services.AddSingleton(aggregator);
+
+ var siteRepo = Substitute.For();
+ siteRepo.GetAllSitesAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ 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(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ }
+
+ [Fact]
+ public void Renders_OutboxKpiTiles_WithValues()
+ {
+ var cut = Render();
+
+ // 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();
+
+ 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);
+ }
+
+ ///
+ /// Stand-in for the notification-outbox actor. Replies to the KPI request
+ /// with the test's currently-scripted response.
+ ///
+ private sealed class ScriptedOutboxActor : ReceiveActor
+ {
+ public ScriptedOutboxActor(HealthPageTests test)
+ {
+ Receive(_ => Sender.Tell(test._kpiReply));
+ }
+ }
+}