feat(kpi): K15 — Audit Log trend charts

This commit is contained in:
Joseph Doherty
2026-06-17 20:30:38 -04:00
parent 4a88355098
commit 3595a41349
5 changed files with 305 additions and 0 deletions
@@ -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 @@
<div class="container-fluid mt-3">
<h1 class="h4 mb-3">Audit Log</h1>
@* 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. *@
<div class="card mb-3" data-test="audit-trends">
<div class="card-header d-flex justify-content-between align-items-center">
<button type="button"
class="btn btn-link p-0 text-decoration-none fw-semibold"
aria-expanded="@_trendsOpen"
@onclick="ToggleTrendsAsync">
Trends
<span class="text-muted small">@(_trendsOpen ? "▾" : "▸")</span>
</button>
@if (_trendsOpen)
{
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
<button type="button"
class="btn @(_windowHours == 24 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(24)">
24h
</button>
<button type="button"
class="btn @(_windowHours == 168 ? "btn-secondary" : "btn-outline-secondary")"
@onclick="() => SetWindowAsync(168)">
7d
</button>
</div>
}
</div>
@if (_trendsOpen)
{
<div class="card-body">
<div class="row g-3">
@foreach (var (metric, title, unit) in TrendMetrics)
{
var series = SeriesFor(metric);
<div class="col-12 col-md-4">
<KpiTrendChart Points="@series.Points"
Title="@title"
Unit="@unit"
IsAvailable="@series.IsAvailable"
ErrorMessage="@series.ErrorMessage" />
</div>
}
</div>
</div>
}
</div>
@* 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. *@
@@ -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!;
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <inheritdoc />
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();
}
}
/// <summary>
/// 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.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Whether the Trends panel is expanded (open by default).</summary>
private bool _trendsOpen = true;
/// <summary>Active window in hours — 24 (default) or 168 (7 days).</summary>
private int _windowHours = 24;
/// <summary>One rendered chart's state: the fetched series + availability.</summary>
private readonly record struct TrendSeries(
IReadOnlyList<KpiSeriesPoint>? Points, bool IsAvailable, string? ErrorMessage);
/// <summary>The metrics rendered in the panel, in display order.</summary>
private static readonly (string Metric, string Title, string? Unit)[] TrendMetrics =
{
("totalEventsLastHour", "Events / hour", null),
("errorEventsLastHour", "Error events / hour", null),
("backlogTotal", "Backlog", null),
};
/// <summary>Per-metric series state, keyed by the metric name.</summary>
private readonly Dictionary<string, TrendSeries> _trendSeries = new();
/// <summary>
/// Fetches every Trends metric for the current window. Each
/// <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> 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.
/// </summary>
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.");
}
}
}
/// <summary>Returns the rendered state for a metric, defaulting to available-empty.</summary>
private TrendSeries SeriesFor(string metric) =>
_trendSeries.TryGetValue(metric, out var s) ? s : new TrendSeries(null, true, null);
/// <summary>Expands/collapses the panel; re-queries when it is (re)shown.</summary>
private async Task ToggleTrendsAsync()
{
_trendsOpen = !_trendsOpen;
if (_trendsOpen)
{
await LoadTrendsAsync();
}
}
/// <summary>Switches the window (24h ↔ 7d) and re-queries every series.</summary>
private async Task SetWindowAsync(int hours)
{
if (_windowHours == hours)
{
return;
}
_windowHours = hours;
await LoadTrendsAsync();
}
/// <summary>
/// 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