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)
+ {
+
+
+ @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);
+ });
+ }
+}