feat(notification-outbox): add outbox KPI tiles to Health dashboard
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
124
tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
Normal file
124
tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user