diff --git a/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor new file mode 100644 index 0000000..624e4a2 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor @@ -0,0 +1,60 @@ +@* + Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the + Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles + in a single row, each acting as a navigation link to a pre-filtered Site + Calls report view. The component is purely presentational — the parent page + owns the refresh loop and passes the latest snapshot via the Snapshot + parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section. +*@ + +@namespace ScadaLink.CentralUI.Components.Health +@inject NavigationManager Navigation + +
+
Site Calls
+ View details → +
+
+ @* ── Buffered tile ─────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Stuck tile ────────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Parked tile ───────────────────────────────────────────────────────── *@ +
+ +
+
+@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage)) +{ +
Site Call KPIs unavailable: @ErrorMessage
+} diff --git a/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs new file mode 100644 index 0000000..1ed9a9a --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.CentralUI.Components.Health; + +/// +/// Site Call Audit (#22) Task 7 code-behind for . +/// Renders three KPI tiles — Buffered, Stuck, Parked — from a +/// the parent Health dashboard supplies. +/// Tiles act as drill-in links: clicking navigates to /site-calls/report +/// with the relevant query-string filter pre-applied. Mirrors +/// and the Notification Outbox KPI section on the +/// Health dashboard. +/// +/// +/// +/// Why purely presentational. The Health dashboard already owns a 10s +/// auto-refresh loop; pushing that into the tile component would either +/// duplicate it (one timer per tile) or awkwardly couple back to the page. The +/// parent passes a fresh every refresh and the +/// tile component re-renders. This is the same contract +/// follows. +/// +/// +/// Snapshot shape. Unlike — which takes a +/// dedicated AuditLogKpiSnapshot type — Site Call KPIs travel in the +/// message itself (it carries the KPI fields +/// directly), so that record doubles as the snapshot here. +/// is a separate flag rather than the record's own Success so the parent +/// can also surface a transport failure (an Ask that threw) as unavailable. +/// +/// +/// Threshold borders. Mirrors the Notification Outbox tile pattern: the +/// Parked tile gets a danger border when ParkedCount > 0; the Stuck +/// tile gets a warning border when StuckCount > 0. Buffered is a plain +/// count tile with no threshold colour — a non-zero buffer is normal operation. +/// +/// +public partial class SiteCallKpiTiles +{ + /// + /// Latest KPI snapshot. null means the parent has not loaded it yet + /// or the load failed — the tiles render em dashes in that case. + /// + [Parameter] public SiteCallKpiResponse? Snapshot { get; set; } + + /// + /// True when is a successful query result. False when + /// the parent's refresh threw, or the response itself reported a fault, and + /// the displayed values should be rendered as em dashes with an error + /// explanation underneath. + /// + [Parameter] public bool IsAvailable { get; set; } + + /// + /// Optional error message to render underneath the tiles when + /// is false. Mirrors how the Notification Outbox + /// section on the Health dashboard surfaces transient KPI failures. + /// + [Parameter] public string? ErrorMessage { get; set; } + + // ── Buffered tile ─────────────────────────────────────────────────────── + + private string BufferedDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.BufferedCount.ToString("N0") + : "—"; + + private void NavigateToBuffered() + { + // Buffered is "everything still in flight" — no single status maps to + // it, so the natural drill-in is the unfiltered Site Calls report sorted + // by newest, mirroring how the Audit volume/backlog tiles drop the + // operator on the unfiltered Audit Log grid. + Navigation.NavigateTo("/site-calls/report"); + } + + // ── Stuck tile ────────────────────────────────────────────────────────── + + private string StuckDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.StuckCount.ToString("N0") + : "—"; + + // Stuck above zero is a warning signal — cached calls that have been + // Pending/Retrying past the stuck-age threshold. Matches the Notification + // Outbox Stuck tile (border-warning when StuckCount > 0). + private string StuckBorderClass => + IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0 + ? "border-warning" + : string.Empty; + + private string StuckTextClass => + IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0 + ? "text-warning" + : string.Empty; + + private void NavigateToStuck() + { + // Drill in with the report's "stuck only" filter pre-applied. + Navigation.NavigateTo("/site-calls/report?stuck=true"); + } + + // ── Parked tile ───────────────────────────────────────────────────────── + + private string ParkedDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.ParkedCount.ToString("N0") + : "—"; + + // Parked above zero is a danger signal — cached calls that exhausted retries + // and need an operator Retry/Discard. Matches the Notification Outbox Parked + // tile (border-danger when ParkedCount > 0). + private string ParkedBorderClass => + IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0 + ? "border-danger" + : string.Empty; + + private string ParkedTextClass => + IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0 + ? "text-danger" + : string.Empty; + + private void NavigateToParked() + { + // Drill in pre-filtered to Parked — the report's Status filter accepts + // ?status=Parked and Parked rows carry the Retry/Discard relay actions. + Navigation.NavigateTo("/site-calls/report?status=Parked"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index 58a87d1..198a161 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -8,6 +8,7 @@ @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.HealthMonitoring @using ScadaLink.Commons.Messages.Notification +@using ScadaLink.Commons.Messages.Audit @using ScadaLink.Communication @implements IDisposable @inject ICentralHealthAggregator HealthAggregator @@ -60,6 +61,12 @@
Notification Outbox KPIs unavailable: @_outboxKpiError
} + @* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel + (buffered / stuck / parked). Refreshed alongside the site states. *@ + + @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@ diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs new file mode 100644 index 0000000..8dfdd04 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs @@ -0,0 +1,177 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Components.Health; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.CentralUI.Tests.Components.Health; + +/// +/// bUnit tests for (Site Call Audit #22, Task 7). +/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked — +/// from a single snapshot. The tests pin: +/// +/// +/// Three-tile render contract (data-test attributes for stable selectors). +/// Tile values render the snapshot's counters. +/// Threshold borders fire correctly — danger on Parked > 0, warning +/// on Stuck > 0, none when those counts are zero, none on Buffered. +/// Unavailable snapshot renders em dashes plus the error message. +/// Tile clicks navigate to the correct pre-filtered Site Calls report URL. +/// +/// +public class SiteCallKpiTilesTests : BunitContext +{ + private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) => + new( + CorrelationId: "k", + Success: true, + ErrorMessage: null, + BufferedCount: buffered, + ParkedCount: parked, + FailedLastInterval: 0, + DeliveredLastInterval: 0, + OldestPendingAge: null, + StuckCount: stuck); + + [Fact] + public void Renders_ThreeTiles_FromSnapshot() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7)) + .Add(c => c.IsAvailable, true)); + + // Three stable data-test selectors — the contract for both these tests + // and any future Playwright sweep. + Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup); + + // Tile values render the snapshot's counters. + Assert.Contains(">120<", cut.Markup); // buffered + Assert.Contains(">7<", cut.Markup); // stuck + Assert.Contains(">3<", cut.Markup); // parked + } + + [Fact] + public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage() + { + var cut = Render(p => p + .Add(c => c.Snapshot, (SiteCallKpiResponse?)null) + .Add(c => c.IsAvailable, false) + .Add(c => c.ErrorMessage, "site call repository unavailable")); + + // All three tiles show em dashes — em dash (U+2014) "—" must appear. + Assert.Contains("—", cut.Markup); + // Inline error message renders below. + Assert.Contains("Site Call KPIs unavailable", cut.Markup); + Assert.Contains("site call repository unavailable", cut.Markup); + } + + [Fact] + public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void ParkedTile_NoDangerBorder_WhenParkedZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void StuckTile_GetsWarningBorder_WhenStuckAboveZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty); + // Warning, not danger — Stuck is the softer signal. + Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void StuckTile_NoWarningBorder_WhenStuckZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount() + { + // A non-zero buffer is normal operation — the Buffered tile is a plain + // count tile and never gets a danger/warning border. + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]"); + var cls = tile.GetAttribute("class") ?? string.Empty; + Assert.DoesNotContain("border-danger", cls); + Assert.DoesNotContain("border-warning", cls); + } + + [Fact] + public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]"); + tile.Click(); + + // Unfiltered /site-calls/report — no query string. + Assert.EndsWith("/site-calls/report", nav.Uri); + } + + [Fact] + public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + tile.Click(); + + // Spec: Stuck tile drills into the report's "stuck only" filter. + Assert.Contains("/site-calls/report?stuck=true", nav.Uri); + } + + [Fact] + public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + tile.Click(); + + // Spec: Parked tile drills into ?status=Parked. + Assert.Contains("/site-calls/report?status=Parked", nav.Uri); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs index 78dbd52..b575f8c 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs @@ -9,6 +9,7 @@ using NSubstitute; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types; using ScadaLink.Communication; @@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3, DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6)); + // Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests + // that target the Site Call tiles override this before rendering. + private SiteCallKpiResponse _siteCallKpiReply = + new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1, + DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3), + StuckCount: 5); + public HealthPageTests() { _comms = new CommunicationService( @@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); _comms.SetNotificationOutbox(outbox); + + var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); + _comms.SetSiteCallAudit(siteCallAudit); Services.AddSingleton(_comms); var aggregator = Substitute.For(); @@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext }); } + [Fact] + public void Renders_SiteCallKpiTiles_WithValues() + { + var cut = Render(); + + // KPI data arrives via an async actor Ask after first render. + cut.WaitForAssertion(() => + { + Assert.Contains("Site Calls", cut.Markup); + // The three Site Call tiles render at the documented data-test selectors. + Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup); + // KPI numeric values surface in the tiles. + Assert.Contains(">9<", cut.Markup); // BufferedCount + Assert.Contains(">5<", cut.Markup); // StuckCount + Assert.Contains(">2<", cut.Markup); // ParkedCount + }); + } + + [Fact] + public void RendersLinkToTheSiteCallsReportPage() + { + var cut = Render(); + var link = cut.Find("a[href='/site-calls/report']"); + Assert.Contains("View details", link.TextContent); + } + + [Fact] + public void SiteCallKpiFailure_ShowsGracefulFallback() + { + _siteCallKpiReply = new SiteCallKpiResponse( + "k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Failure must not crash the page; tiles fall back to a dash and the + // inline error message surfaces. + Assert.Contains("Site Calls", cut.Markup); + Assert.Contains("Site Call KPIs unavailable", cut.Markup); + Assert.Contains("site call repository unavailable", cut.Markup); + Assert.Contains(">—<", cut.Markup); + }); + } + [Fact] public void OutboxKpiFailure_ShowsGracefulFallback() { @@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext Receive(_ => Sender.Tell(test._kpiReply)); } } + + /// + /// Stand-in for the Site Call Audit actor. Replies to the KPI request with + /// the test's currently-scripted response. + /// + private sealed class ScriptedSiteCallAuditActor : ReceiveActor + { + public ScriptedSiteCallAuditActor(HealthPageTests test) + { + Receive(_ => Sender.Tell(test._siteCallKpiReply)); + } + } }