feat(central-ui): Notification KPIs page with per-site breakdown
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
@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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.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.Commons.Types.Notifications;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit rendering tests for the Notification KPIs page.
|
||||||
|
///
|
||||||
|
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||||
|
/// non-virtual methods, so NSubstitute cannot intercept it. Both the global and
|
||||||
|
/// per-site KPI calls route through an injected <see cref="IActorRef"/> (the
|
||||||
|
/// notification-outbox proxy), so the tests wire a real, lightweight
|
||||||
|
/// <see cref="ActorSystem"/> with a scripted <see cref="ReceiveActor"/> that
|
||||||
|
/// answers both <see cref="NotificationKpiRequest"/> and
|
||||||
|
/// <see cref="PerSiteNotificationKpiRequest"/> — the same seam
|
||||||
|
/// <c>SetNotificationOutbox</c> exists for.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationKpisPageTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly ActorSystem _system = ActorSystem.Create("notif-kpis-tests");
|
||||||
|
private readonly CommunicationService _comms;
|
||||||
|
|
||||||
|
// Mutable scripted replies — individual tests can override before rendering.
|
||||||
|
private NotificationKpiResponse _kpiReply =
|
||||||
|
new("k", true, null, QueueDepth: 7, StuckCount: 2, ParkedCount: 1,
|
||||||
|
DeliveredLastInterval: 42, OldestPendingAge: TimeSpan.FromMinutes(9));
|
||||||
|
|
||||||
|
private PerSiteNotificationKpiResponse _perSiteReply =
|
||||||
|
new("p", true, null, new List<SiteNotificationKpiSnapshot>
|
||||||
|
{
|
||||||
|
new("plant-a", QueueDepth: 4, StuckCount: 1, ParkedCount: 0,
|
||||||
|
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
|
||||||
|
});
|
||||||
|
|
||||||
|
public NotificationKpisPageTests()
|
||||||
|
{
|
||||||
|
_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 siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||||
|
{
|
||||||
|
new("Plant A", "plant-a") { Id = 1 },
|
||||||
|
new("Plant B", "plant-b") { Id = 2 },
|
||||||
|
}));
|
||||||
|
Services.AddSingleton(siteRepo);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim("Username", "tester"),
|
||||||
|
new Claim(ClaimTypes.Role, "Deployment"),
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Page_RequiresDeploymentPolicy()
|
||||||
|
{
|
||||||
|
var attr = typeof(NotificationKpisPage)
|
||||||
|
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||||
|
.Cast<AuthorizeAttribute>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
Assert.NotNull(attr);
|
||||||
|
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RendersGlobalTilesAndPerSiteRows()
|
||||||
|
{
|
||||||
|
var cut = Render<NotificationKpisPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Contains("Queue Depth", cut.Markup);
|
||||||
|
Assert.Contains("7", cut.Markup); // global tile value
|
||||||
|
// Per-site row — site identifier "plant-a" resolves to its friendly name.
|
||||||
|
Assert.Contains("Plant A", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowsKpiError_WhenGlobalKpiQueryFails()
|
||||||
|
{
|
||||||
|
_kpiReply = new NotificationKpiResponse(
|
||||||
|
"k", false, "kpi down", 0, 0, 0, 0, null);
|
||||||
|
|
||||||
|
var cut = Render<NotificationKpisPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowsPerSiteEmptyState_WhenNoSites()
|
||||||
|
{
|
||||||
|
_perSiteReply = new PerSiteNotificationKpiResponse(
|
||||||
|
"p", true, null, new List<SiteNotificationKpiSnapshot>());
|
||||||
|
|
||||||
|
var cut = Render<NotificationKpisPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", 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 each KPI message
|
||||||
|
/// type with the test's currently-scripted response.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||||
|
{
|
||||||
|
public ScriptedOutboxActor(NotificationKpisPageTests test)
|
||||||
|
{
|
||||||
|
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||||
|
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user