From 3595a41349b9b17f33fba0a4b4357e8ae40e1e84 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 20:30:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(kpi):=20K15=20=E2=80=94=20Audit=20Log=20tr?= =?UTF-8?q?end=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Pages/Audit/AuditLogPage.razor | 51 +++++++ .../Pages/Audit/AuditLogPage.razor.cs | 107 +++++++++++++ .../Pages/AuditLogPagePermissionTests.cs | 3 + .../Pages/AuditLogPageScaffoldTests.cs | 3 + .../Pages/AuditLogPageTrendTests.cs | 141 ++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor index 667467fc..8d0a0612 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -1,6 +1,7 @@ @page "/audit/log" @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit +@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit @using ZB.MOM.WW.ScadaBridge.Security @@ -11,6 +12,56 @@

Audit Log

+ @* Trends panel (M6 / K15). A best-effort collapsible Bootstrap card sitting + above the audit query UI: one KpiTrendChart per AuditLog global metric over + a 24h (default) / 7d window. Series are fetched independently in the + code-behind — a failed fetch degrades only that chart to the unavailable + placeholder and never breaks the filter bar / grid below. *@ +
+
+ + @if (_trendsOpen) + { +
+ + +
+ } +
+ @if (_trendsOpen) + { +
+
+ @foreach (var (metric, title, unit) in TrendMetrics) + { + var series = SeriesFor(metric); +
+ +
+ } +
+
+ } +
+ @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. Bundle D (M7-T10..T12) threads a query-string instance prefill through InitialInstanceSearch — UI-only because the filter contract has no instance column. *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 30cdae08..dfce1c10 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.WebUtilities; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit; @@ -48,6 +49,13 @@ public partial class AuditLogPage : IDisposable { [Inject] private NavigationManager Navigation { get; set; } = null!; + /// + /// KPI-history facade for the M6 (K15) Trends panel — fetched per metric, + /// per window. Best-effort: a failed series fetch degrades that one chart to + /// the unavailable placeholder and never breaks the audit query UI. + /// + [Inject] private IKpiHistoryQueryService KpiHistory { get; set; } = null!; + private AuditLogQueryFilter? _currentFilter; private AuditEventView? _selectedEvent; private bool _drawerOpen; @@ -60,6 +68,18 @@ public partial class AuditLogPage : IDisposable Navigation.LocationChanged += HandleLocationChanged; } + /// + protected override async Task OnInitializedAsync() + { + // The Trends panel is open by default, so load its series on first render. + // Done in OnInitializedAsync (not OnInitialized) so the per-metric awaits + // do not block the synchronous query-string drill-in parsing above. + if (_trendsOpen) + { + await LoadTrendsAsync(); + } + } + /// /// Re-applies the query-string drill-in filters when the URL changes while /// this page stays routed (e.g. the drawer's "View parent execution" action @@ -237,6 +257,93 @@ public partial class AuditLogPage : IDisposable _drawerOpen = false; } + // ───────────────────────────────────────────────────────────────────────── + // M6 (K15) — Audit Log trend charts. + // + // A best-effort Trends panel that sits above the audit query UI: one + // KpiTrendChart per AuditLog global metric, over a 24h (default) or 7d + // window. Each series is fetched independently and a failure degrades only + // that chart (IsAvailable=false + short message) — the audit query UI is + // never affected. Re-queries on init (panel open by default), on the window + // toggle, and when the panel is re-shown after being collapsed. + // ───────────────────────────────────────────────────────────────────────── + + /// Whether the Trends panel is expanded (open by default). + private bool _trendsOpen = true; + + /// Active window in hours — 24 (default) or 168 (7 days). + private int _windowHours = 24; + + /// One rendered chart's state: the fetched series + availability. + private readonly record struct TrendSeries( + IReadOnlyList? Points, bool IsAvailable, string? ErrorMessage); + + /// The metrics rendered in the panel, in display order. + private static readonly (string Metric, string Title, string? Unit)[] TrendMetrics = + { + ("totalEventsLastHour", "Events / hour", null), + ("errorEventsLastHour", "Error events / hour", null), + ("backlogTotal", "Backlog", null), + }; + + /// Per-metric series state, keyed by the metric name. + private readonly Dictionary _trendSeries = new(); + + /// + /// Fetches every Trends metric for the current window. Each + /// call is wrapped so a + /// failure surfaces as an unavailable chart rather than throwing out of the + /// page — audit-log functionality must never depend on KPI history being up. + /// + private async Task LoadTrendsAsync() + { + var toUtc = DateTime.UtcNow; + var fromUtc = toUtc - TimeSpan.FromHours(_windowHours); + + foreach (var (metric, _, _) in TrendMetrics) + { + try + { + var points = await KpiHistory.GetSeriesAsync( + KpiSources.AuditLog, metric, KpiScopes.Global, scopeKey: null, + fromUtc, toUtc); + _trendSeries[metric] = new TrendSeries(points, IsAvailable: true, ErrorMessage: null); + } + catch (Exception) + { + // Best-effort: degrade this chart only, keep the rest of the page alive. + _trendSeries[metric] = new TrendSeries( + Points: null, IsAvailable: false, ErrorMessage: "Trend data unavailable."); + } + } + } + + /// Returns the rendered state for a metric, defaulting to available-empty. + private TrendSeries SeriesFor(string metric) => + _trendSeries.TryGetValue(metric, out var s) ? s : new TrendSeries(null, true, null); + + /// Expands/collapses the panel; re-queries when it is (re)shown. + private async Task ToggleTrendsAsync() + { + _trendsOpen = !_trendsOpen; + if (_trendsOpen) + { + await LoadTrendsAsync(); + } + } + + /// Switches the window (24h ↔ 7d) and re-queries every series. + private async Task SetWindowAsync(int hours) + { + if (_windowHours == hours) + { + return; + } + + _windowHours = hours; + await LoadTrendsAsync(); + } + /// /// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most /// recently applied filter as query-string params so the server-side diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index 2e84b8f6..841f4031 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -71,6 +71,9 @@ public class AuditLogPagePermissionTests : BunitContext // a permitted render is exercised end-to-end. Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); + // M6 (K15): the page now injects IKpiHistoryQueryService for the Trends + // panel — register a stand-in so a permitted render is exercised end-to-end. + Services.AddSingleton(Substitute.For()); } private IRenderedComponent RenderAuditLogPage(params string[] roles) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 5bd7c756..1370d588 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -62,6 +62,9 @@ public class AuditLogPageScaffoldTests : BunitContext // Provide stand-ins so the scaffold smoke tests still render the page. Services.AddSingleton(Substitute.For()); Services.AddSingleton(_queryService); + // M6 (K15): the page now injects IKpiHistoryQueryService for the Trends + // panel. Provide a stand-in so the scaffold/drill-in renders still work. + Services.AddSingleton(Substitute.For()); if (!string.IsNullOrEmpty(query)) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs new file mode 100644 index 00000000..76fbbfb1 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageTrendTests.cs @@ -0,0 +1,141 @@ +using System.Security.Claims; +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; +using ZB.MOM.WW.ScadaBridge.Security; +using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; + +/// +/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one +/// KpiTrendChart per AuditLog global metric, over a 24h/7d window. The +/// panel is best-effort: a failing must +/// degrade the affected charts without breaking the audit query UI (the filter +/// bar + results grid that share the page). +/// +/// +/// bUnit hosts the page directly, so the test registers every service the page +/// and its child components inject: + +/// the real authorization policies (the page carries +/// [Authorize(OperationalAudit)] and an in-page AuthorizeView for +/// the Export button), (AuditFilterBar), +/// (AuditResultsGrid), and the new +/// (the Trends panel). The page is wrapped +/// in a the router would supply in +/// production. +/// +/// +public class AuditLogPageTrendTests : BunitContext +{ + private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc); + + public AuditLogPageTrendTests() + { + // AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX + // through audit-grid.js. Loose mode lets those unconfigured JS calls no-op + // so these trend tests need not configure browser interop. + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static ClaimsPrincipal BuildPrincipal(params string[] roles) + { + var claims = new List { new(JwtTokenService.UsernameClaimType, "tester") }; + claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + } + + private static IReadOnlyList ThreePoints() => new[] + { + new KpiSeriesPoint(Base, 1.0), + new KpiSeriesPoint(Base.AddMinutes(20), 4.0), + new KpiSeriesPoint(Base.AddMinutes(40), 2.0), + }; + + private IRenderedComponent RenderAuditLogPage(IKpiHistoryQueryService kpiHistory) + { + var user = BuildPrincipal("Administrator"); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaBridgeAuthorization(Services); + Services.AddSingleton(); + + // The page hosts AuditFilterBar + AuditResultsGrid (Bundle B) and the + // K15 Trends panel — register a stand-in for each injected dependency. + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(kpiHistory); + + var host = Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }))); + + return host.FindComponent(); + } + + [Fact] + public void TrendsPanel_WithSeries_RendersChartsWithPolyline() + { + var kpi = Substitute.For(); + kpi.GetSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(ThreePoints())); + + var cut = RenderAuditLogPage(kpi); + + cut.WaitForAssertion(() => + { + // The panel container is present... + Assert.Contains("data-test=\"audit-trends\"", cut.Markup); + // ...and at least one chart rendered an actual polyline series. + Assert.Contains("kpi-trend-", cut.Markup); + Assert.Contains("(), Arg.Any(), Arg.Any(), Arg.Any()); + kpi.Received().GetSeriesAsync( + KpiSources.AuditLog, "errorEventsLastHour", KpiScopes.Global, null, + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + kpi.Received().GetSeriesAsync( + KpiSources.AuditLog, "backlogTotal", KpiScopes.Global, null, + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders() + { + // Best-effort contract: a failing KPI-history query degrades the charts to + // the unavailable placeholder but must NOT throw out of the page render. + var kpi = Substitute.For(); + kpi.GetSeriesAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("kpi down")); + + var cut = RenderAuditLogPage(kpi); + + cut.WaitForAssertion(() => + { + // The page rendered — heading + trends panel container both present — + // even though every series fetch threw. + Assert.Contains("Audit Log", cut.Markup); + Assert.Contains("data-test=\"audit-trends\"", cut.Markup); + }); + } +}