refactor(central-ui): split Notification Report out of the Outbox page
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
@page "/monitoring/notification-outbox"
|
@page "/notifications/report"
|
||||||
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@@ -7,70 +7,19 @@
|
|||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@inject ILogger<NotificationOutbox> Logger
|
@inject ILogger<NotificationReport> Logger
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Notification Outbox</h4>
|
<h4 class="mb-0">Notification Report</h4>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
|
<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> }
|
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── KPI tiles ── *@
|
|
||||||
@if (_kpiError != null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* ── Filters ── *@
|
@* ── Filters ── *@
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@@ -259,11 +208,6 @@
|
|||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
|
|
||||||
// KPIs
|
|
||||||
private NotificationKpiResponse _kpi =
|
|
||||||
new(string.Empty, true, null, 0, 0, 0, 0, null);
|
|
||||||
private string? _kpiError;
|
|
||||||
|
|
||||||
// List
|
// List
|
||||||
private List<NotificationSummary>? _notifications;
|
private List<NotificationSummary>? _notifications;
|
||||||
private int _totalCount;
|
private int _totalCount;
|
||||||
@@ -291,7 +235,7 @@
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Non-fatal — source-site filter just falls back to the raw site IDs.
|
// Non-fatal — source-site filter just falls back to the raw site IDs.
|
||||||
Logger.LogWarning(ex, "Failed to load sites for the outbox source-site filter.");
|
Logger.LogWarning(ex, "Failed to load sites for the report source-site filter.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await RefreshAll();
|
await RefreshAll();
|
||||||
@@ -299,31 +243,7 @@
|
|||||||
|
|
||||||
private async Task RefreshAll()
|
private async Task RefreshAll()
|
||||||
{
|
{
|
||||||
// Race-free despite both tasks mutating component fields: Blazor Server runs
|
await FetchPage();
|
||||||
// every continuation on the circuit's single-threaded synchronization context.
|
|
||||||
await Task.WhenAll(LoadKpis(), FetchPage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadKpis()
|
|
||||||
{
|
|
||||||
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 Search()
|
private async Task Search()
|
||||||
@@ -463,16 +383,6 @@
|
|||||||
|
|
||||||
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
|
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string StatusBadgeClass(string status) => status switch
|
private static string StatusBadgeClass(string status) => status switch
|
||||||
{
|
{
|
||||||
"Delivered" => "bg-success",
|
"Delivered" => "bg-success",
|
||||||
@@ -22,8 +22,8 @@ namespace ScadaLink.CentralUI.Tests.Pages;
|
|||||||
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
|
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
|
||||||
/// <see cref="CommunicationService"/> is a concrete class whose outbox calls
|
/// <see cref="CommunicationService"/> is a concrete class whose outbox calls
|
||||||
/// route through an injected notification-outbox <see cref="IActorRef"/>; the
|
/// route through an injected notification-outbox <see cref="IActorRef"/>; the
|
||||||
/// tests reuse the scripted-actor seam established by the Notification Outbox
|
/// tests reuse the scripted-actor seam established by the Notification Report
|
||||||
/// page tests (see <c>NotificationOutboxPageTests</c>).
|
/// page tests (see <c>NotificationReportPageTests</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HealthPageTests : BunitContext
|
public class HealthPageTests : BunitContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,30 +13,26 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using NotificationOutboxPage = ScadaLink.CentralUI.Components.Pages.Monitoring.NotificationOutbox;
|
using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Tests.Pages;
|
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// bUnit rendering tests for the Notification Outbox monitoring page (Task 23).
|
/// bUnit rendering tests for the Notification Report page.
|
||||||
///
|
///
|
||||||
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||||
/// non-virtual methods, so NSubstitute cannot intercept it. The outbox calls all
|
/// non-virtual methods, so NSubstitute cannot intercept it. The report calls all
|
||||||
/// route through an injected <see cref="IActorRef"/> (the notification-outbox
|
/// route through an injected <see cref="IActorRef"/> (the notification-outbox
|
||||||
/// proxy), so the tests wire a real, lightweight <see cref="ActorSystem"/> with a
|
/// proxy), so the tests wire a real, lightweight <see cref="ActorSystem"/> with a
|
||||||
/// scripted <see cref="ReceiveActor"/> that replies with fixed responses — the
|
/// scripted <see cref="ReceiveActor"/> that replies with fixed responses — the
|
||||||
/// same seam <c>SetNotificationOutbox</c> exists for.
|
/// same seam <c>SetNotificationOutbox</c> exists for.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotificationOutboxPageTests : BunitContext
|
public class NotificationReportPageTests : BunitContext
|
||||||
{
|
{
|
||||||
private readonly ActorSystem _system = ActorSystem.Create("notif-outbox-tests");
|
private readonly ActorSystem _system = ActorSystem.Create("notif-report-tests");
|
||||||
private readonly CommunicationService _comms;
|
private readonly CommunicationService _comms;
|
||||||
|
|
||||||
// Mutable scripted replies — individual tests can override before rendering.
|
// Mutable scripted reply — 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 NotificationOutboxQueryResponse _queryReply =
|
private NotificationOutboxQueryResponse _queryReply =
|
||||||
new("q", true, null, new List<NotificationSummary>
|
new("q", true, null, new List<NotificationSummary>
|
||||||
{
|
{
|
||||||
@@ -54,7 +50,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
private readonly List<RetryNotificationRequest> _retryRequests = new();
|
private readonly List<RetryNotificationRequest> _retryRequests = new();
|
||||||
private readonly List<DiscardNotificationRequest> _discardRequests = new();
|
private readonly List<DiscardNotificationRequest> _discardRequests = new();
|
||||||
|
|
||||||
public NotificationOutboxPageTests()
|
public NotificationReportPageTests()
|
||||||
{
|
{
|
||||||
_comms = new CommunicationService(
|
_comms = new CommunicationService(
|
||||||
Options.Create(new CommunicationOptions()),
|
Options.Create(new CommunicationOptions()),
|
||||||
@@ -88,7 +84,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Page_RequiresDeploymentPolicy()
|
public void Page_RequiresDeploymentPolicy()
|
||||||
{
|
{
|
||||||
var attr = typeof(NotificationOutboxPage)
|
var attr = typeof(NotificationReportPage)
|
||||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||||
.Cast<AuthorizeAttribute>()
|
.Cast<AuthorizeAttribute>()
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@@ -97,28 +93,10 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Renders_KpiTiles_WithValues()
|
|
||||||
{
|
|
||||||
var cut = Render<NotificationOutboxPage>();
|
|
||||||
|
|
||||||
// KPI data arrives via an async actor Ask after first render.
|
|
||||||
cut.WaitForAssertion(() =>
|
|
||||||
{
|
|
||||||
Assert.Contains("Queue Depth", cut.Markup);
|
|
||||||
Assert.Contains("Stuck", cut.Markup);
|
|
||||||
Assert.Contains("Parked", cut.Markup);
|
|
||||||
Assert.Contains("Delivered", cut.Markup);
|
|
||||||
// KPI numeric values surface in the tiles.
|
|
||||||
Assert.Contains(">7<", cut.Markup); // QueueDepth
|
|
||||||
Assert.Contains(">42<", cut.Markup); // DeliveredLastInterval
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Renders_NotificationRows()
|
public void Renders_NotificationRows()
|
||||||
{
|
{
|
||||||
var cut = Render<NotificationOutboxPage>();
|
var cut = Render<NotificationReportPage>();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -131,7 +109,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void StuckRow_IsBadged()
|
public void StuckRow_IsBadged()
|
||||||
{
|
{
|
||||||
var cut = Render<NotificationOutboxPage>();
|
var cut = Render<NotificationReportPage>();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
@@ -147,7 +125,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ClickRetry_OnParkedRow_CallsRetryNotification()
|
public void ClickRetry_OnParkedRow_CallsRetryNotification()
|
||||||
{
|
{
|
||||||
var cut = Render<NotificationOutboxPage>();
|
var cut = Render<NotificationReportPage>();
|
||||||
|
|
||||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
@@ -168,7 +146,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
|
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
|
||||||
{
|
{
|
||||||
var cut = Render<NotificationOutboxPage>();
|
var cut = Render<NotificationReportPage>();
|
||||||
|
|
||||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
@@ -186,18 +164,6 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void KpiFailure_ShowsErrorMessage()
|
|
||||||
{
|
|
||||||
_kpiReply = new NotificationKpiResponse(
|
|
||||||
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
|
|
||||||
|
|
||||||
var cut = Render<NotificationOutboxPage>();
|
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
|
||||||
Assert.Contains("outbox repository unavailable", cut.Markup));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void QueryFailure_ShowsErrorMessage()
|
public void QueryFailure_ShowsErrorMessage()
|
||||||
{
|
{
|
||||||
@@ -205,7 +171,7 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
"q", false, "outbox query backend unavailable",
|
"q", false, "outbox query backend unavailable",
|
||||||
new List<NotificationSummary>(), TotalCount: 0);
|
new List<NotificationSummary>(), TotalCount: 0);
|
||||||
|
|
||||||
var cut = Render<NotificationOutboxPage>();
|
var cut = Render<NotificationReportPage>();
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
Assert.Contains("outbox query backend unavailable", cut.Markup));
|
Assert.Contains("outbox query backend unavailable", cut.Markup));
|
||||||
@@ -226,9 +192,8 @@ public class NotificationOutboxPageTests : BunitContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||||
{
|
{
|
||||||
public ScriptedOutboxActor(NotificationOutboxPageTests test)
|
public ScriptedOutboxActor(NotificationReportPageTests test)
|
||||||
{
|
{
|
||||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
|
||||||
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
||||||
Receive<RetryNotificationRequest>(r =>
|
Receive<RetryNotificationRequest>(r =>
|
||||||
{
|
{
|
||||||
Reference in New Issue
Block a user