diff --git a/docs/plans/2026-05-20-auditlog-m7-central-ui.md b/docs/plans/2026-05-20-auditlog-m7-central-ui.md new file mode 100644 index 0000000..ba2765a --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m7-central-ui.md @@ -0,0 +1,31 @@ +# Audit Log #23 — M7 Central UI Implementation Plan + +> **For Claude:** subagent-driven-development with bundled cadence. + +**Goal:** User-visible Audit Log page in the Central UI: filter bar, results grid with keyset paging, drilldown drawer with JSON pretty-print + cURL + redaction badges, drill-ins from Notifications/Site Calls/External Systems/Inbound API Keys/Sites/Instances, 3 KPI tiles on Health dashboard, server-side streaming CSV export, OperationalAudit+AuditExport permission gating, Playwright E2E. + +**UI memory constraints (locked):** +- Blazor Server + Bootstrap CSS only. NO third-party UI libraries (no Blazorise, MudBlazor, Radzen, Prism.js, Highlight.js, etc.). +- Custom Blazor components for tables/grids/forms. +- Clean corporate aesthetic. +- Form layout: vertical stacking, read-only fields first, subsections stacked, buttons at bottom. +- Use the frontend-design skill IF dispatched UI-design subagents need pattern guidance. + +**M6 realities baked in:** +- `IAuditCentralHealthSnapshot` aggregates CentralAuditWriteFailures + AuditRedactionFailure + per-site stalled. Health tiles read this. +- `SiteHealthReport.SiteAuditBacklog` ready for per-site display. +- `IAuditLogRepository.QueryAsync` keyset-paged; data source for the grid. +- Pre-existing `Components/Pages/Monitoring/AuditLog.razor` (IAuditService config-change viewer) must be renamed → `Components/Pages/Audit/ConfigurationAuditLog.razor` with route `/audit/configuration`. Old route returns 404 (no redirect — internal tool, no external bookmarks). +- Need to add `OperationalAudit` + `AuditExport` permission strings. + +**SQL highlighting decision:** no third-party highlighter. Render `
` block with `language-sql` class and let any future CSS theme it; semantic markup is preserved without JS dependency.
+
+**Bundles:**
+- Bundle A — Page scaffold + nav + ConfigurationAuditLog rename (T1, T9)
+- Bundle B — Filter bar + results grid (T2, T3)
+- Bundle C — Drilldown drawer (T4, T5, T6, T7, T8)
+- Bundle D — Drill-ins from other pages (T10, T11, T12)
+- Bundle E — Health dashboard KPI tiles (T13)
+- Bundle F — CSV export (T14)
+- Bundle G — Permissions (T15)
+- Bundle H — Playwright E2E (T16)
diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
new file mode 100644
index 0000000..fdf34f6
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
@@ -0,0 +1,173 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.CentralUI.Services;
+using ScadaLink.Commons.Types.Audit;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.Security;
+
+namespace ScadaLink.CentralUI.Audit;
+
+/// 
+/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
+///
+/// 
+/// CentralUI ships no MVC controllers (see 
+/// and ),
+/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
+/// The endpoint streams to Response.Body directly so the export does NOT
+/// buffer the full result set in memory — see
+/// .
+/// 
+///
+/// 
+/// The route is gated on the 
+/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export
+/// permission can pull a CSV — the page-level
+///  gate is read-only
+/// and intentionally narrower. The query-string parser silently drops
+/// unrecognised values to match the page-level parser in
+/// AuditLogPage.ApplyQueryStringFilters — an unknown enum value yields
+/// the same "no constraint" outcome rather than a 400.
+/// 
+/// 
+public static class AuditExportEndpoints
+{
+    /// 
+    /// Default row cap for a single export. Large enough to satisfy realistic
+    /// operator workflows; mirrors the brief's recommended ceiling. Operators
+    /// who need more should fall back to the CLI (footnote rendered in the
+    /// cap-footer line).
+    /// 
+    public const int DefaultMaxRows = 100_000;
+
+    public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
+    {
+        endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
+            .RequireAuthorization(AuthorizationPolicies.AuditExport);
+
+        return endpoints;
+    }
+
+    /// 
+    /// Handles GET /api/centralui/audit/export. Internal so endpoint
+    /// tests can call it directly when desirable; the live wire-up goes
+    /// through the minimal-API map above.
+    /// 
+    internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
+    {
+        var filter = ParseFilter(context.Request.Query);
+        var maxRows = ParseMaxRows(context.Request.Query);
+
+        // Stamp the response headers BEFORE the first body write so the client
+        // sees text/csv + an attachment download right away.
+        var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
+        context.Response.ContentType = "text/csv; charset=utf-8";
+        context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
+        // Defeat any intermediate buffering proxy so the operator sees rows
+        // streaming through as the server flushes each repository page.
+        context.Response.Headers["Cache-Control"] = "no-store";
+
+        await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted);
+    }
+
+    /// 
+    /// Parses the query-string into an .
+    /// Unknown enum names / un-parseable Guids / dates are silently dropped
+    /// (same contract as AuditLogPage.ApplyQueryStringFilters).
+    /// 
+    internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
+    {
+        AuditChannel? channel = null;
+        if (query.TryGetValue("channel", out var channelValues)
+            && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
+        {
+            channel = parsedChannel;
+        }
+
+        AuditKind? kind = null;
+        if (query.TryGetValue("kind", out var kindValues)
+            && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind))
+        {
+            kind = parsedKind;
+        }
+
+        AuditStatus? status = null;
+        if (query.TryGetValue("status", out var statusValues)
+            && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
+        {
+            status = parsedStatus;
+        }
+
+        string? site = TrimToNullable(query, "site");
+        string? target = TrimToNullable(query, "target");
+        string? actor = TrimToNullable(query, "actor");
+
+        Guid? correlationId = null;
+        if (query.TryGetValue("correlationId", out var corrValues)
+            && Guid.TryParse(corrValues.ToString(), out var parsedCorr))
+        {
+            correlationId = parsedCorr;
+        }
+
+        DateTime? fromUtc = ParseUtcDate(query, "from");
+        DateTime? toUtc = ParseUtcDate(query, "to");
+
+        return new AuditLogQueryFilter(
+            Channel: channel,
+            Kind: kind,
+            Status: status,
+            SourceSiteId: site,
+            Target: target,
+            Actor: actor,
+            CorrelationId: correlationId,
+            FromUtc: fromUtc,
+            ToUtc: toUtc);
+    }
+
+    /// 
+    /// Optional maxRows= query-string override. Falls back to
+    ///  on a missing / non-positive / unparseable
+    /// value rather than erroring — same lax contract as the rest of the
+    /// query parser.
+    /// 
+    private static int ParseMaxRows(IQueryCollection query)
+    {
+        if (query.TryGetValue("maxRows", out var raw)
+            && int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+            && parsed > 0)
+        {
+            return parsed;
+        }
+        return DefaultMaxRows;
+    }
+
+    private static string? TrimToNullable(IQueryCollection query, string key)
+    {
+        if (!query.TryGetValue(key, out var values))
+        {
+            return null;
+        }
+        var v = values.ToString();
+        return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
+    }
+
+    private static DateTime? ParseUtcDate(IQueryCollection query, string key)
+    {
+        if (!query.TryGetValue(key, out var values))
+        {
+            return null;
+        }
+        if (DateTime.TryParse(
+            values.ToString(),
+            CultureInfo.InvariantCulture,
+            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+            out var parsed))
+        {
+            return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
+        }
+        return null;
+    }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
new file mode 100644
index 0000000..f580fb8
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
@@ -0,0 +1,161 @@
+@using ScadaLink.Commons.Entities.Audit
+@using ScadaLink.Commons.Types.Enums
+
+@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
+   Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
+   All form/field rendering follows the form-layout memory:
+   read-only fields first (definition list), then subsections stacked,
+   action buttons at the bottom of the drawer. *@
+
+@if (IsOpen && Event is not null)
+{
+    
+
+
+
+
Audit event
+
Audit Event @ShortEventId(Event.EventId)
+
+ +
+ +
+ @* Read-only field list — primary identification + provenance. *@ +
+
Channel / Kind
+
@Event.Channel / @Event.Kind
+ +
Status
+
@Event.Status
+ +
HttpStatus
+
@(Event.HttpStatus?.ToString() ?? "—")
+ +
Target
+
@(Event.Target ?? "—")
+ +
Actor
+
@(Event.Actor ?? "—")
+ +
SourceSiteId
+
@(Event.SourceSiteId ?? "—")
+ +
SourceInstanceId
+
@(Event.SourceInstanceId ?? "—")
+ +
SourceScript
+
@(Event.SourceScript ?? "—")
+ +
CorrelationId
+
@(Event.CorrelationId?.ToString() ?? "—")
+ +
OccurredAtUtc
+
@FormatTimestamp(Event.OccurredAtUtc)
+ +
IngestedAtUtc
+
@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")
+ +
DurationMs
+
@(Event.DurationMs?.ToString() ?? "—")
+
+ + @* Error subsection — only shown when there is something to report. *@ + @if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail)) + { +
+
Error
+ @if (!string.IsNullOrEmpty(Event.ErrorMessage)) + { +

@Event.ErrorMessage

+ } + @if (!string.IsNullOrEmpty(Event.ErrorDetail)) + { +
@Event.ErrorDetail
+ } +
+ } + + @* Request body (channel-aware renderer). *@ + @if (!string.IsNullOrEmpty(Event.RequestSummary)) + { +
+
+ Request + @if (IsRedacted(Event.RequestSummary)) + { + + Redacted + + } +
+
+ @RenderBody(Event.RequestSummary!, Event.Channel) +
+
+ } + + @* Response body (channel-aware renderer). *@ + @if (!string.IsNullOrEmpty(Event.ResponseSummary)) + { +
+
+ Response + @if (IsRedacted(Event.ResponseSummary)) + { + + Redacted + + } +
+
+ @RenderBody(Event.ResponseSummary!, Event.Channel) +
+
+ } + + @* Extra is always JSON when present. *@ + @if (!string.IsNullOrEmpty(Event.Extra)) + { +
+
Extra
+
@PrettyPrintJson(Event.Extra!)
+
+ } +
+ + @* Action buttons at the bottom per form-layout memory. *@ + +
+} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs new file mode 100644 index 0000000..77a2847 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs @@ -0,0 +1,374 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8). +/// Renders one in a right-side off-canvas drawer: +/// read-only fields, conditional Error/Request/Response/Extra subsections, +/// and action buttons (Copy as cURL, Show all events for this operation, +/// Close). The drawer is fully presentational — it has no DB or service +/// dependencies; the host page owns the open/close state. +/// +/// +/// Body rendering. Request/Response/Extra summaries are strings. +/// The drawer pretty-prints JSON when it parses; falls back to verbatim +/// otherwise. DbOutbound payloads carry a {sql, parameters} JSON +/// shape and get a SQL code block plus a parameter definition list. +/// Syntax highlighting is CSS-class-only (language-sql); no JS +/// library is loaded — Blazor Server + Bootstrap only per the project's UI +/// rules. +/// +/// +/// +/// Redaction badges. The audit pipeline replaces redacted values +/// with the literal sentinels <redacted> or +/// <redacted: redactor error> (see Component-AuditLog.md +/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body +/// section when its text contains either sentinel — it does not attempt +/// to un-redact or count occurrences. +/// +/// +/// +/// Copy as cURL. Best-effort: the URL comes from Target; +/// when the RequestSummary parses as {headers, body}, headers are +/// folded into -H flags and the body into --data-raw. The +/// command is written to the system clipboard via +/// . We only +/// surface the button for API channels (ApiOutbound / ApiInbound). +/// +/// +/// +/// Drill-back. When is set, +/// the "Show all events" button navigates to +/// /audit/log?correlationId={id}. The parent page does not +/// auto-apply that filter today — it is a deep link the page can use +/// when Bundle D wires up query-string deserialization. +/// +/// +public partial class AuditDrilldownDrawer +{ + [Inject] private IJSRuntime JS { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + + /// + /// The row to render. When null the drawer renders nothing — the host + /// page uses this together with to drive visibility. + /// + [Parameter] public AuditEvent? Event { get; set; } + + /// + /// True when the host wants the drawer visible. We deliberately keep + /// this as a separate parameter from : an open + /// drawer briefly with a null event renders nothing (the row may still + /// be loading); a closed drawer with a stale event is the resting state. + /// + [Parameter] public bool IsOpen { get; set; } + + /// + /// Fired when the user dismisses the drawer (close button or backdrop + /// click). The host is expected to flip to false. + /// + [Parameter] public EventCallback OnClose { get; set; } + + private const string RedactionSentinel = ""; + private const string RedactorErrorSentinel = ""; + + private static bool IsApiChannel(AuditChannel channel) + => channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound; + + private static string ShortEventId(Guid eventId) + { + // Mirror the "first 8 hex digits" presentation common across the UI. + var n = eventId.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + + private static string FormatTimestamp(DateTime utc) + { + // Force UTC kind in case the row arrived as Unspecified, then emit + // round-trip ISO-8601 so audit drilldowns are copy-paste safe. + var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc; + return kind.ToString("o", CultureInfo.InvariantCulture); + } + + private static bool IsRedacted(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + return text.Contains(RedactionSentinel, StringComparison.Ordinal) + || text.Contains(RedactorErrorSentinel, StringComparison.Ordinal); + } + + /// + /// Channel-aware body renderer. DbOutbound bodies that parse as + /// {sql, parameters} get a SQL block + parameter list; anything + /// else falls back to JSON-pretty-print, then plain-text verbatim. + /// + private RenderFragment RenderBody(string body, AuditChannel channel) => builder => + { + // DbOutbound special-case: try to extract {sql, parameters}. + if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters)) + { + builder.OpenElement(0, "pre"); + builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre"); + builder.OpenElement(2, "code"); + // Highlighting is CSS-class-only — no JS library is loaded. + builder.AddAttribute(3, "class", "language-sql"); + builder.AddContent(4, sql); + builder.CloseElement(); + builder.CloseElement(); + + if (parameters is not null && parameters.Count > 0) + { + builder.OpenElement(10, "dl"); + builder.AddAttribute(11, "class", "row mb-0 small"); + builder.AddAttribute(12, "data-test", "sql-parameters"); + // The analyzer (ASP0006) requires literal sequence numbers + // inside a render fragment. We delegate parameter rendering + // to a helper fragment that uses a stable @key per entry, + // so per-row diffing stays correct even though the outer + // sequence number is fixed. + builder.AddContent(13, BuildSqlParameterRows(parameters)); + builder.CloseElement(); + } + return; + } + + // Generic JSON pretty-print path. + if (TryPrettyPrintJson(body, out var pretty)) + { + builder.OpenElement(20, "pre"); + builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json"); + builder.AddContent(22, pretty); + builder.CloseElement(); + return; + } + + // Fallback: verbatim. Wrapping in
 preserves whitespace, which
+        // is useful when the body is multi-line plain text or a partial JSON.
+        builder.OpenElement(30, "pre");
+        builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
+        builder.AddContent(32, body);
+        builder.CloseElement();
+    };
+
+    private static RenderFragment BuildSqlParameterRows(List> parameters) => builder =>
+    {
+        foreach (var kv in parameters)
+        {
+            // Literal sequence numbers (ASP0006) + per-element SetKey so
+            // Blazor's diff is still keyed on parameter name. The "0" base
+            // is fine here — each loop iteration produces a disjoint
+            // dt/dd pair, and the diff keys on @key, not sequence.
+            builder.OpenElement(0, "dt");
+            builder.SetKey($"dt-{kv.Key}");
+            builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
+            builder.AddContent(2, kv.Key);
+            builder.CloseElement();
+
+            builder.OpenElement(3, "dd");
+            builder.SetKey($"dd-{kv.Key}");
+            builder.AddAttribute(4, "class", "col-8 font-monospace");
+            builder.AddContent(5, kv.Value);
+            builder.CloseElement();
+        }
+    };
+
+    private static bool TryPrettyPrintJson(string text, out string formatted)
+    {
+        formatted = text;
+        try
+        {
+            using var doc = JsonDocument.Parse(text);
+            formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
+            return true;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string PrettyPrintJson(string text)
+        => TryPrettyPrintJson(text, out var pretty) ? pretty : text;
+
+    /// 
+    /// Best-effort parse of a DbOutbound {sql, parameters} body.
+    /// Returns true only when the JSON has a string sql property;
+    /// parameters is treated as an optional object whose values
+    /// stringify to scalar text.
+    /// 
+    private static bool TryParseDbBody(string text, out string sql, out List>? parameters)
+    {
+        sql = string.Empty;
+        parameters = null;
+        try
+        {
+            using var doc = JsonDocument.Parse(text);
+            if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
+            if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
+                return false;
+            sql = sqlProp.GetString() ?? string.Empty;
+
+            if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
+                && paramsProp.ValueKind == JsonValueKind.Object)
+            {
+                parameters = new List>();
+                foreach (var p in paramsProp.EnumerateObject())
+                {
+                    parameters.Add(new KeyValuePair(p.Name, StringifyJsonValue(p.Value)));
+                }
+            }
+            return true;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
+    {
+        JsonValueKind.String => value.GetString() ?? string.Empty,
+        JsonValueKind.Null => "null",
+        JsonValueKind.True => "true",
+        JsonValueKind.False => "false",
+        JsonValueKind.Number => value.GetRawText(),
+        _ => value.GetRawText(),
+    };
+
+    private async Task HandleClose()
+    {
+        if (OnClose.HasDelegate)
+        {
+            await OnClose.InvokeAsync();
+        }
+    }
+
+    private async Task CopyCurl()
+    {
+        if (Event is null) return;
+
+        var curl = BuildCurlCommand(Event);
+        try
+        {
+            await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
+        }
+        catch
+        {
+            // Clipboard interop can fail (denied permission, prerender, etc.).
+            // The drawer stays open; the failure surfaces in the dev console
+            // only — we deliberately do not toast here because the parent
+            // page owns toast state.
+        }
+    }
+
+    private void ShowAllForOperation()
+    {
+        if (Event?.CorrelationId is not { } corr) return;
+        var uri = $"/audit/log?correlationId={corr}";
+        Navigation.NavigateTo(uri);
+    }
+
+    /// 
+    /// Build a cURL command from an audit event. The URL comes from
+    /// Target; when the RequestSummary parses as
+    /// {headers, body, method?}, headers fold into -H flags
+    /// and the body into --data-raw. Default method is POST for
+    /// outbound audit rows — the audit pipeline does not always capture
+    /// the verb explicitly.
+    /// 
+    private static string BuildCurlCommand(AuditEvent ev)
+    {
+        var sb = new StringBuilder();
+        sb.Append("curl");
+
+        string method = "POST";
+        List>? headers = null;
+        string? body = null;
+
+        if (!string.IsNullOrEmpty(ev.RequestSummary))
+        {
+            TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
+        }
+
+        sb.Append(' ').Append("-X ").Append(method);
+
+        if (headers is not null)
+        {
+            foreach (var (name, value) in headers)
+            {
+                sb.Append(' ').Append("-H ");
+                sb.Append(QuoteShellArg($"{name}: {value}"));
+            }
+        }
+
+        if (!string.IsNullOrEmpty(body))
+        {
+            sb.Append(' ').Append("--data-raw ");
+            sb.Append(QuoteShellArg(body!));
+        }
+
+        var url = ev.Target ?? string.Empty;
+        sb.Append(' ').Append(QuoteShellArg(url));
+        return sb.ToString();
+    }
+
+    private static void TryExtractCurlPartsFromJson(
+        string requestSummary,
+        ref string method,
+        ref List>? headers,
+        ref string? body)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(requestSummary);
+            if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
+
+            if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
+            {
+                method = m.GetString() ?? method;
+            }
+            if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
+            {
+                headers = new List>();
+                foreach (var h in hs.EnumerateObject())
+                {
+                    var value = h.Value.ValueKind == JsonValueKind.String
+                        ? h.Value.GetString() ?? string.Empty
+                        : h.Value.GetRawText();
+                    headers.Add(new KeyValuePair(h.Name, value));
+                }
+            }
+            if (doc.RootElement.TryGetProperty("body", out var b))
+            {
+                body = b.ValueKind == JsonValueKind.String
+                    ? b.GetString()
+                    : b.GetRawText();
+            }
+        }
+        catch (JsonException)
+        {
+            // RequestSummary wasn't the expected {headers, body} shape —
+            // just produce a bare cURL with no body/headers.
+        }
+    }
+
+    /// 
+    /// Quote a single shell argument with single quotes, escaping embedded
+    /// single quotes via the standard '\'' idiom. This is the same
+    /// quoting strategy curl examples use across man pages.
+    /// 
+    private static string QuoteShellArg(string value)
+    {
+        if (string.IsNullOrEmpty(value)) return "''";
+        var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
+        return $"'{escaped}'";
+    }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css
new file mode 100644
index 0000000..58133ba
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css
@@ -0,0 +1,40 @@
+/* Audit Log drilldown drawer (#23 M7 Bundle C).
+   The base offcanvas + backdrop classes come from Bootstrap. The local
+   overrides below pin our preferred width and pre-block behaviour. */
+
+.audit-drilldown-drawer {
+    /* Slightly wider than the parked-messages drawer because audit rows can
+       carry larger JSON bodies and SQL blocks. Clamp to viewport so narrow
+       windows still get the close button on screen. */
+    width: min(720px, 95vw);
+}
+
+.audit-drilldown-drawer .drawer-pre {
+    /* Wrap long lines and bound the per-block height so the drawer body
+       stays scrollable end-to-end instead of pushing the action buttons
+       below the fold. */
+    white-space: pre-wrap;
+    word-break: break-word;
+    max-height: 320px;
+    overflow-y: auto;
+    margin: 0;
+    font-size: 0.8125rem;
+}
+
+.audit-drilldown-drawer .drawer-pre.json {
+    /* JSON blocks get a faint left rule so they read as quoted material. */
+    border-left: 3px solid var(--bs-info-border-subtle);
+}
+
+.audit-drilldown-drawer .drawer-pre code.language-sql {
+    /* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
+       a slightly different background so the SQL block reads distinct from
+       generic JSON pretty-prints without loading a syntax-highlighter JS
+       library. */
+    font-family: var(--bs-font-monospace);
+    color: var(--bs-emphasis-color);
+}
+
+.audit-drilldown-drawer .drawer-footer {
+    background-color: var(--bs-tertiary-bg);
+}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
new file mode 100644
index 0000000..8601e5f
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
@@ -0,0 +1,156 @@
+@using ScadaLink.Commons.Entities.Sites
+@using ScadaLink.Commons.Interfaces.Repositories
+@using ScadaLink.Commons.Types.Audit
+@using ScadaLink.Commons.Types.Enums
+@inject ISiteRepository SiteRepository
+
+
+
+ @* Channel chip multi-select. *@ +
+ +
+ @foreach (var channel in Enum.GetValues()) + { + var selected = _model.Channels.Contains(channel); + + } +
+
+ + @* Kind chip multi-select — narrowed by Channel selection. *@ +
+ +
+ @foreach (var kind in _model.VisibleKinds()) + { + var selected = _model.Kinds.Contains(kind); + + } +
+
+ + @* Status chip multi-select. *@ +
+ +
+ @foreach (var status in Enum.GetValues()) + { + var selected = _model.Statuses.Contains(status); + + } +
+
+ + @* Site chip multi-select — populated from ISiteRepository. *@ +
+ +
+ @if (_sites.Count == 0) + { + No sites available. + } + else + { + @foreach (var site in _sites) + { + var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier); + + } + } +
+
+ +
+
+ + +
+ + @* Custom datetime range; only the pickers are conditional, the wrapper is + always emitted so tests can find it. *@ +
+ @if (_model.TimeRange == AuditTimeRangePreset.Custom) + { +
+
+ + +
+
+ + +
+
+ } + else + { + Window: @TimeRangeLabel(_model.TimeRange) + } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs new file mode 100644 index 0000000..ba052d3 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the +/// binding state, renders the 10 filter elements +/// plus the Errors-only toggle, and publishes a collapsed +/// via when the +/// user clicks Apply. See for the multi-select → +/// single-value collapse contract. +/// +public partial class AuditFilterBar +{ + private readonly AuditQueryModel _model = new(); + private List _sites = new(); + + /// + /// Raised when the user clicks Apply. Carries the collapsed + /// the parent page hands to + /// . + /// + [Parameter] public EventCallback OnFilterChanged { get; set; } + + /// + /// Test seam: overriding "now" is needed to make the time-range collapse tests + /// stable in unit suites. Production callers leave this null and the model + /// uses . + /// + [Parameter] public Func? NowUtcProvider { get; set; } + + /// + /// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render, + /// pre-populates the Instance free-text input. Instance is UI-only — the + /// repository filter contract has no instance column — so this flows in + /// through a separate parameter rather than the + /// the parent page passes to the grid. + /// + [Parameter] public string? InitialInstanceSearch { get; set; } + + protected override async Task OnInitializedAsync() + { + // One-shot prefill from a drill-in deep link. Subsequent parameter changes + // do NOT overwrite user input — the field is owned by the operator after + // first render. + if (!string.IsNullOrWhiteSpace(InitialInstanceSearch)) + { + _model.InstanceSearch = InitialInstanceSearch.Trim(); + } + + + // Populate the Site chips at component init. Failure is non-fatal — the chip + // section just shows "No sites available." Sites are listed by Name to match + // operator expectations from the Notification Report. + try + { + var sites = await SiteRepository.GetAllSitesAsync(); + _sites = sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch + { + // Swallowed: filter bar still renders without the Site chips. The page + // surfaces site-load errors elsewhere (the grid query path). + _sites = new(); + } + } + + private void ToggleChannel(AuditChannel channel) + { + if (!_model.Channels.Add(channel)) + { + _model.Channels.Remove(channel); + } + + // Drop Kind chips that fall outside the new visible set. Keeps "Channel and + // Kind both picked" coherent — without this, removing a channel could leave + // stale Kind chips selected that no longer match any visible chip. + var visible = _model.VisibleKinds().ToHashSet(); + _model.Kinds.RemoveWhere(k => !visible.Contains(k)); + } + + private void ToggleKind(AuditKind kind) + { + if (!_model.Kinds.Add(kind)) + { + _model.Kinds.Remove(kind); + } + } + + private void ToggleStatus(AuditStatus status) + { + if (!_model.Statuses.Add(status)) + { + _model.Statuses.Remove(status); + } + } + + private void ToggleSite(string siteIdentifier) + { + if (!_model.SiteIdentifiers.Add(siteIdentifier)) + { + _model.SiteIdentifiers.Remove(siteIdentifier); + } + } + + private void ClearFilters() + { + _model.Channels.Clear(); + _model.Kinds.Clear(); + _model.Statuses.Clear(); + _model.SiteIdentifiers.Clear(); + _model.TimeRange = AuditTimeRangePreset.LastHour; + _model.CustomFromUtc = null; + _model.CustomToUtc = null; + _model.InstanceSearch = string.Empty; + _model.ScriptSearch = string.Empty; + _model.TargetSearch = string.Empty; + _model.ActorSearch = string.Empty; + _model.ErrorsOnly = false; + } + + private async Task Apply() + { + var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow; + var filter = _model.ToFilter(now); + await OnFilterChanged.InvokeAsync(filter); + } + + private static string ChipClass(bool selected) => + selected + ? "btn btn-sm btn-primary me-1 mb-1" + : "btn btn-sm btn-outline-secondary me-1 mb-1"; + + private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch + { + AuditTimeRangePreset.Last5Minutes => "now − 5 min → now", + AuditTimeRangePreset.LastHour => "now − 1h → now", + AuditTimeRangePreset.Last24Hours => "now − 24h → now", + _ => "—", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs new file mode 100644 index 0000000..6ed9e70 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// UI-side binding model for (#23 M7-T2). +/// +/// +/// The model mirrors but allows multi-select chip +/// state for Channel / Kind / Status / Site (each a ) plus +/// extra UI-only fields the underlying filter does not carry: the Errors-only toggle, +/// the time-range preset, and free-text Instance / Script searches. +/// +/// +/// +/// The repository filter contract () is single-value +/// per dimension today; the chip multi-selects therefore collapse to the FIRST +/// selected chip when the model is published via . That is a +/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can +/// either repeat the query per chip or widen the filter contract without rewriting +/// the form. Instance and Script free-text are also UI-only today: the underlying +/// filter has no matching columns, so they are dropped during collapse. +/// +/// +/// +/// The Errors-only toggle is a convenience: when true AND no explicit Status chips +/// are selected, the collapsed filter pins (the +/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle +/// is a no-op — the explicit Status filter wins. +/// +/// +public sealed class AuditQueryModel +{ + public HashSet Channels { get; } = new(); + public HashSet Kinds { get; } = new(); + public HashSet Statuses { get; } = new(); + public HashSet SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase); + + public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour; + public DateTime? CustomFromUtc { get; set; } + public DateTime? CustomToUtc { get; set; } + + public string InstanceSearch { get; set; } = string.Empty; + public string ScriptSearch { get; set; } = string.Empty; + public string TargetSearch { get; set; } = string.Empty; + public string ActorSearch { get; set; } = string.Empty; + + public bool ErrorsOnly { get; set; } + + /// + /// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4). + /// CachedSubmit and CachedResolve appear under both + /// and + /// because the cached-call lifecycle is channel-agnostic at submit/resolve time. + /// Used by the filter bar to narrow the Kind chip list once Channels are picked. + /// + public static readonly IReadOnlyDictionary> KindsByChannel = + new Dictionary> + { + [AuditChannel.ApiOutbound] = ImmutableList.Create( + AuditKind.ApiCall, AuditKind.ApiCallCached, + AuditKind.CachedSubmit, AuditKind.CachedResolve), + [AuditChannel.DbOutbound] = ImmutableList.Create( + AuditKind.DbWrite, AuditKind.DbWriteCached, + AuditKind.CachedSubmit, AuditKind.CachedResolve), + [AuditChannel.Notification] = ImmutableList.Create( + AuditKind.NotifySend, AuditKind.NotifyDeliver), + [AuditChannel.ApiInbound] = ImmutableList.Create( + AuditKind.InboundRequest, AuditKind.InboundAuthFailure), + }; + + /// + /// Returns the kinds visible in the Kind chip list given the currently selected + /// Channels. With no Channel selected, all 10 kinds are visible (no narrowing). + /// With one or more Channels selected, the union of the channel-specific kind + /// lists is returned (deduplicated and order-stable on first-seen). + /// + public IReadOnlyList VisibleKinds() + { + if (Channels.Count == 0) + { + return Enum.GetValues(); + } + + var seen = new HashSet(); + var result = new List(); + foreach (var ch in Channels) + { + if (!KindsByChannel.TryGetValue(ch, out var kinds)) + { + continue; + } + foreach (var k in kinds) + { + if (seen.Add(k)) + { + result.Add(k); + } + } + } + return result; + } + + /// + /// Collapses this UI model to the repository's single-value filter. + /// See class doc for the multi-select → single-value contract. + /// + public AuditLogQueryFilter ToFilter(DateTime utcNow) + { + var status = ResolveStatus(); + + var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); + + return new AuditLogQueryFilter( + Channel: Channels.Count > 0 ? Channels.First() : null, + Kind: Kinds.Count > 0 ? Kinds.First() : null, + Status: status, + SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null, + Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), + Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), + CorrelationId: null, + FromUtc: fromUtc, + ToUtc: toUtc); + } + + private AuditStatus? ResolveStatus() + { + if (Statuses.Count > 0) + { + // Explicit chips win — Errors-only is a no-op. + return Statuses.First(); + } + + if (ErrorsOnly) + { + // Single-value filter contract: Failed is the lead non-success status. + // When the filter widens to multi-value the full {Failed, Parked, Discarded} + // set will flow through. + return AuditStatus.Failed; + } + + return null; + } + + private (DateTime? From, DateTime? To) ResolveTimeWindow(DateTime utcNow) + { + return TimeRange switch + { + AuditTimeRangePreset.Last5Minutes => (utcNow.AddMinutes(-5), null), + AuditTimeRangePreset.LastHour => (utcNow.AddHours(-1), null), + AuditTimeRangePreset.Last24Hours => (utcNow.AddHours(-24), null), + AuditTimeRangePreset.Custom => (CustomFromUtc, CustomToUtc), + _ => (null, null), + }; + } +} + +/// +/// Time-range presets surfaced in the filter bar. reveals the +/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to +/// "now" at the moment Apply is clicked. +/// +public enum AuditTimeRangePreset +{ + Last5Minutes, + LastHour, + Last24Hours, + Custom, +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor new file mode 100644 index 0000000..df000aa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -0,0 +1,111 @@ +@using ScadaLink.CentralUI.Components.Shared +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Entities.Audit +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Commons.Types.Enums +@inject IAuditLogQueryService QueryService + +
+ @if (_error is not null) + { +
@_error
+ } + +
+ + + + @foreach (var col in OrderedColumns()) + { + + } + + + + @if (_rows.Count == 0) + { + + + + } + else + { + @foreach (var row in _rows) + { + + @foreach (var col in OrderedColumns()) + { + + } + + } + } + +
@col.Label
+ @if (_loading) + { + Loading… + } + else + { + No audit events match the current filter. + } +
+ @RenderCell(col.Key, row) +
+
+ +
+ Page @_pageNumber · @_rows.Count rows + +
+
+ +@code { + private RenderFragment RenderCell(string key, AuditEvent row) => __builder => + { + switch (key) + { + case "OccurredAtUtc": + var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)); + + + + break; + case "Site": + @(row.SourceSiteId ?? "—") + break; + case "Channel": + @row.Channel + break; + case "Kind": + @row.Kind + break; + case "Status": + @row.Status + break; + case "Target": + @(row.Target ?? "—") + break; + case "Actor": + @(row.Actor ?? "—") + break; + case "DurationMs": + @(row.DurationMs?.ToString() ?? "—") + break; + case "HttpStatus": + @(row.HttpStatus?.ToString() ?? "—") + break; + case "ErrorMessage": + @TruncateError(row.ErrorMessage) + break; + } + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs new file mode 100644 index 0000000..cfbae61 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -0,0 +1,199 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). +/// Renders the 10 columns named in Component-AuditLog.md §10: +/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, +/// HttpStatus, ErrorMessage. Talks to +/// — never to IAuditLogRepository directly — so tests can stub the data +/// source without standing up EF Core. +/// +/// +/// Column model. Each column has a stable string key; the visible order +/// is the parameter. M7 scope: the column-model +/// framework is in place but resize / drag-reorder UX is intentionally NOT +/// implemented — the full spec calls for persisted-per-user reordering and +/// resizing, which M7.x can ship without rewriting the column model. Resizing +/// today is CSS-based via Bootstrap's .table-responsive wrapper. +/// +/// +/// +/// Pagination. Each page is a single call to +/// IAuditLogQueryService.QueryAsync. The "Next page" button uses the +/// LAST row of the current page as the keyset cursor — repository orders by +/// (OccurredAtUtc DESC, EventId DESC), so the oldest row in the visible +/// page becomes AfterOccurredAtUtc + AfterEventId on the next +/// request. The button is disabled when the current page is short (less than +/// rows) — that's the conventional "we've reached the +/// end" signal for keyset paging without a count query. +/// +/// +public partial class AuditResultsGrid +{ + private const int DefaultPageSize = 100; + + private readonly List _rows = new(); + private int _pageNumber = 1; + private bool _loading; + private string? _error; + + private AuditLogQueryFilter? _activeFilter; + + /// + /// Filter to apply. When this parameter changes the grid resets to page 1 and + /// reissues the query — that's the contract the parent page relies on so the + /// filter-bar Apply button does not need to drive grid state manually. + /// + [Parameter] public AuditLogQueryFilter? Filter { get; set; } + + /// Page size. Defaults to 100 to match the service-level default. + [Parameter] public int PageSize { get; set; } = DefaultPageSize; + + /// + /// Optional column order — list of column keys in display order. When null or + /// empty the default order from Component-AuditLog.md §10 is used. The grid + /// silently drops unknown keys. + /// + [Parameter] public IReadOnlyList? ColumnOrder { get; set; } + + /// + /// Raised when the user clicks a row. Bundle C wires this to the drilldown + /// drawer. The event payload is the full . + /// + [Parameter] public EventCallback OnRowSelected { get; set; } + + // Effective page size used when paging. Mirrors PageSize but bounded > 0. + private int _pageSize => Math.Max(1, PageSize); + + /// + /// Default column definitions. The key is the stable identifier (used by + /// data-test + the column-order parameter); the label is the user-facing + /// header text. Mirrors Component-AuditLog.md §10. + /// + private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] + { + ("OccurredAtUtc", "OccurredAtUtc"), + ("Site", "Site"), + ("Channel", "Channel"), + ("Kind", "Kind"), + ("Status", "Status"), + ("Target", "Target"), + ("Actor", "Actor"), + ("DurationMs", "DurationMs"), + ("HttpStatus", "HttpStatus"), + ("ErrorMessage", "ErrorMessage"), + }; + + private IReadOnlyList<(string Key, string Label)> OrderedColumns() + { + if (ColumnOrder is null || ColumnOrder.Count == 0) + { + return AllColumns; + } + + var byKey = AllColumns.ToDictionary(c => c.Key, c => c); + var ordered = new List<(string Key, string Label)>(ColumnOrder.Count); + foreach (var key in ColumnOrder) + { + if (byKey.TryGetValue(key, out var col)) + { + ordered.Add(col); + } + } + return ordered.Count == 0 ? AllColumns : ordered; + } + + protected override async Task OnParametersSetAsync() + { + // Reset & reload whenever the filter reference changes. AuditLogQueryFilter + // is a record, so equality-by-value gives us a free "did the user click Apply + // with the same chips?" no-op signal. We pin to ReferenceEquals here so the + // grid reloads only when the parent hands us a new filter instance — the + // page wraps Apply in a fresh allocation, which is the canonical reload signal. + if (!ReferenceEquals(_activeFilter, Filter)) + { + _activeFilter = Filter; + _pageNumber = 1; + _rows.Clear(); + if (Filter is not null) + { + await LoadAsync(paging: null); + } + } + } + + private async Task NextPage() + { + if (_rows.Count == 0 || _activeFilter is null) + { + return; + } + + var last = _rows[^1]; + var cursor = new AuditLogPaging( + PageSize: _pageSize, + AfterOccurredAtUtc: last.OccurredAtUtc, + AfterEventId: last.EventId); + + await LoadAsync(cursor); + _pageNumber++; + } + + private async Task LoadAsync(AuditLogPaging? paging) + { + if (_activeFilter is null) + { + return; + } + + _loading = true; + _error = null; + try + { + var effective = paging ?? new AuditLogPaging(_pageSize); + var page = await QueryService.QueryAsync(_activeFilter, effective); + _rows.Clear(); + _rows.AddRange(page); + } + catch (Exception ex) + { + // Surface the error in-place; the grid stays alive so the user can + // adjust the filter and retry without a page refresh. + _error = $"Query failed: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + private async Task HandleRowClick(AuditEvent row) + { + if (OnRowSelected.HasDelegate) + { + await OnRowSelected.InvokeAsync(row); + } + } + + private static string StatusBadgeClass(AuditStatus status) => status switch + { + AuditStatus.Delivered => "badge bg-success", + AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger", + _ => "badge bg-secondary", + }; + + private static string TruncateError(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return "—"; + } + const int max = 80; + return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor new file mode 100644 index 0000000..0113fc0 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor @@ -0,0 +1,59 @@ +@* + Audit Log (#23) M7 Bundle E (T13) — three Health-dashboard KPI tiles for the + Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in + a single row, each acting as a navigation link to a pre-filtered Audit Log + view. The component is purely presentational — the parent page owns the + refresh loop and passes the latest snapshot via the Snapshot parameter. +*@ + +@namespace ScadaLink.CentralUI.Components.Health +@inject NavigationManager Navigation + +
+
Audit
+ View details → +
+
+ @* ── Volume tile ───────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Error rate tile ───────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Backlog tile ──────────────────────────────────────────────────────── *@ +
+ +
+
+@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage)) +{ +
Audit KPIs unavailable: @ErrorMessage
+} diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs new file mode 100644 index 0000000..5c6ede1 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.Components.Health; + +/// +/// Audit Log (#23) M7 Bundle E (T13) code-behind for . +/// Renders three KPI tiles — volume, error rate, backlog — from a +/// the parent page supplies. Tiles act as +/// drill-in links: clicking navigates to /audit/log with the relevant +/// query-string filter pre-applied (Bundle D already parses these params). +/// +/// +/// +/// Why purely presentational. The Health dashboard already owns a 10s +/// auto-refresh loop and an "as-of" timestamp display; pushing those concerns +/// into the tile component would either duplicate them (one timer per tile) or +/// awkwardly couple back to the page. The parent passes a fresh +/// every refresh and the tile component +/// re-renders. +/// +/// +/// Error rate division. When TotalEventsLastHour == 0 we render +/// "0%" rather than "—" — the snapshot itself is available, the system just had +/// no audit traffic to evaluate. This avoids a divide-by-zero AND keeps the +/// "0% errors" reading semantically true. The em dash is reserved for +/// = false, which represents a failed snapshot +/// query (different signal from "quiet hour"). +/// +/// +public partial class AuditKpiTiles +{ + /// + /// 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 AuditLogKpiSnapshot? Snapshot { get; set; } + + /// + /// True when is a successful query result. False + /// when the parent's refresh threw 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; } + + // ── Volume tile ───────────────────────────────────────────────────────── + + private string VolumeDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.TotalEventsLastHour.ToString("N0") + : "—"; + + private void NavigateToVolume() + { + // Volume is "all audit rows in the last hour" — no status filter; the + // page's existing instance-search seam is enough for drill-in. We rely + // on the page's default render which omits a time-range constraint and + // shows the newest rows first. + Navigation.NavigateTo("/audit/log"); + } + + // ── Error rate tile ───────────────────────────────────────────────────── + + /// + /// Percentage of error rows (Failed/Parked/Discarded) over the trailing + /// hour. Returns 0 when the snapshot is unavailable OR when total events + /// is zero (rather than throwing). The display layer renders "—" for the + /// unavailable case and "0%" for the zero-events case. + /// + internal double ErrorRatePercent + { + get + { + if (!IsAvailable || Snapshot is null || Snapshot.TotalEventsLastHour <= 0) + { + return 0; + } + return 100.0 * Snapshot.ErrorEventsLastHour / Snapshot.TotalEventsLastHour; + } + } + + private string ErrorRateDisplay + { + get + { + if (!IsAvailable || Snapshot is null) + { + return "—"; + } + // Format to one decimal so a 1-error-in-2000 rate doesn't round to 0%. + return $"{ErrorRatePercent:0.0}%"; + } + } + + // Border + text colour bracket the tile visually: any nonzero error rate + // gets a warning border; anything above 10% bumps it to danger. The + // thresholds match the Notification Outbox tile pattern (border-warning + // when Stuck > 0, border-danger when Parked > 0). + private string ErrorRateBorderClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "border-danger" : "border-warning"); + + private string ErrorRateTextClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "text-danger" : "text-warning"); + + private void NavigateToErrors() + { + // Drill in pre-filtered to Failed — the most common error class. + // (The Audit Log page also accepts ?status=Parked / =Discarded for + // operators who want to see those specifically; the tile picks Failed + // as the primary surface since it's the only synchronous-failure + // status. Parked + Discarded both still appear in the unfiltered grid.) + Navigation.NavigateTo("/audit/log?status=Failed"); + } + + // ── Backlog tile ──────────────────────────────────────────────────────── + + private string BacklogDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.BacklogTotal.ToString("N0") + : "—"; + + // Backlog above zero is itself a signal — sites should normally drain to + // empty. We render warning when there's a backlog at all; a hard danger + // threshold could be added later if ops want it but the on-call playbook + // for "backlog > 0" is the same as "backlog > 1000": check why the site + // isn't draining. + private string BacklogBorderClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "border-warning" + : string.Empty; + + private string BacklogTextClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "text-warning" + : string.Empty; + + private void NavigateToBacklog() + { + // The audit-log page itself doesn't carry a per-site backlog grid — + // the Health dashboard already shows that per-site card. The natural + // drill-in for "the system has a backlog" is the unfiltered Audit Log + // page sorted by newest, so an operator can see the most recent rows + // and judge whether the queue is moving. + Navigation.NavigateTo("/audit/log"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 78fb4f2..1c05b7e 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -108,11 +108,22 @@ - @* Audit Log — Admin only *@ - + @* Audit — gated on the OperationalAudit policy (#23 M7-T15 + / Bundle G). Hosts the new Audit Log page (#23 M7) and + the renamed Configuration Audit Log (IAuditService + config-change viewer). Both items share the same gate, + so the section header sits inside the same policy block: + a non-audit user does not even see the heading. + OperationalAudit is satisfied by the Admin, Audit, and + AuditReadOnly roles. *@ + + + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor index b47872d..e6bd069 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor @@ -27,6 +27,17 @@ @:Add API Key } + @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log + pre-filtered to this API key's inbound calls. Inbound audit rows record + the key Name as Actor and live on the ApiInbound channel. *@ + @if (IsEditMode && !string.IsNullOrWhiteSpace(_formName)) + { + + Recent audit activity + + } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor index 3c80438..7755334 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor @@ -20,7 +20,20 @@
-
@(IsEditMode ? "Edit Site" : "Add Site")
+
+
@(IsEditMode ? "Edit Site" : "Add Site")
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit + Log pre-filtered to this site's events. AuditEvent.SourceSiteId + stores the SiteIdentifier (string), so we pass that through. *@ + @if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier)) + { + + Recent audit activity + + } +
Audit Log + +
+

Audit Log

+ + @* 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. *@ +
+ +
+ + @* Export button (Bundle F / M7-T14). A plain link triggers the + streaming CSV endpoint at /api/centralui/audit/export — chosen over a + SignalR-driven download because the request can stream 100k rows directly + to the response body without buffering through the Blazor circuit. The + href reflects the most recently applied filter; before Apply is clicked, + an unconstrained export is exposed. + + Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an + OperationalAudit-only operator (read access without bulk export) sees the + page + filters but cannot trigger the CSV pull. The endpoint itself is + gated separately, so a hand-crafted URL still 403s — the AuthorizeView + here is the user-facing affordance, not the authoritative check. *@ + + + + + + + @* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's + drilldown drawer; the grid stays in "no events" mode until the user applies a + filter so the page does not auto-load the full audit table on first render. *@ +
+ +
+
+ +@* Drilldown drawer (Bundle C / M7-T4..T8). Hosted at the page level so the + off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@ + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs new file mode 100644 index 0000000..b3c05ff --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Pages.Audit; + +/// +/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3) +/// wires up AuditFilterBar and AuditResultsGrid: the page owns the +/// active and re-pushes a fresh instance to the +/// grid on every Apply (the grid uses reference identity as its "reload" +/// trigger). Row clicks land in — Bundle C wires +/// this to the drilldown drawer; for now it is a no-op seam so test stubs do +/// not error. +/// +/// +/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can +/// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, +/// ?actor=, ?site=, ?channel=, and the UI-only +/// ?instance= are read on initialization. Bundle E (M7-T13) extends +/// this with ?status= so the Health-dashboard Audit error-rate tile can +/// drill in to ?status=Failed. When any param is present we allocate a +/// fresh and assign it to +/// , which kicks the results grid into auto-load +/// without the user clicking Apply. Unknown values (e.g. an invalid enum name) +/// are silently dropped — the page still renders, just without that constraint. +/// +/// +public partial class AuditLogPage +{ + [Inject] private NavigationManager Navigation { get; set; } = null!; + + private AuditLogQueryFilter? _currentFilter; + private AuditEvent? _selectedEvent; + private bool _drawerOpen; + private string? _initialInstanceSearch; + + protected override void OnInitialized() + { + ApplyQueryStringFilters(); + } + + private void ApplyQueryStringFilters() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.Count == 0) + { + return; + } + + Guid? correlationId = null; + if (query.TryGetValue("correlationId", out var corrValues) + && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) + { + correlationId = parsedCorr; + } + + string? target = null; + if (query.TryGetValue("target", out var targetValues)) + { + var v = targetValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + target = v.Trim(); + } + } + + string? actor = null; + if (query.TryGetValue("actor", out var actorValues)) + { + var v = actorValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + actor = v.Trim(); + } + } + + string? site = null; + if (query.TryGetValue("site", out var siteValues)) + { + var v = siteValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + site = v.Trim(); + } + } + + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in + // with ?status=Failed (and operators may craft URLs with Parked/Discarded). + // Unknown values are silently dropped — the page still renders without + // the constraint. + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) + { + status = parsedStatus; + } + + // Instance is UI-only — the filter contract has no matching column, so we + // pass it as a separate seam to the filter bar. + if (query.TryGetValue("instance", out var instanceValues)) + { + var v = instanceValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + _initialInstanceSearch = v.Trim(); + } + } + + // If ANY filter-shaped param was provided, allocate the filter so the grid + // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load + // because the filter contract has no instance column — the user still needs + // to refine + Apply for those. + if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null) + { + return; + } + + _currentFilter = new AuditLogQueryFilter( + Channel: channel, + Status: status, + SourceSiteId: site, + Target: target, + Actor: actor, + CorrelationId: correlationId); + } + + private void HandleFilterChanged(AuditLogQueryFilter filter) + { + // Always reassign — the grid keys reloads on reference change, so even a + // chip-for-chip identical filter must allocate a fresh instance. + _currentFilter = filter; + } + + private void HandleRowSelected(AuditEvent row) + { + // Bundle C: a grid row click hands us the full AuditEvent. We pin it as + // the selected row and open the drilldown drawer — the drawer is fully + // presentational so we do not need to refetch the row. + _selectedEvent = row; + _drawerOpen = true; + } + + private void HandleDrawerClose() + { + // We deliberately keep _selectedEvent set so re-opening (e.g. via the + // grid) shows the same row instantly without a re-render flicker. + _drawerOpen = false; + } + + /// + /// 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 + /// streaming endpoint reproduces the user's current view. With no filter + /// applied yet, returns the bare endpoint — i.e. an unconstrained export. + /// + /// + /// Built here rather than in markup so the per-row test coverage can + /// exercise the URL composition without booting the full Blazor renderer. + /// + internal string ExportUrl => BuildExportUrl(_currentFilter); + + internal static string BuildExportUrl(AuditLogQueryFilter? filter) + { + const string basePath = "/api/centralui/audit/export"; + if (filter is null) + { + return basePath; + } + + var parts = new List>(9); + if (filter.Channel is { } ch) + { + parts.Add(new("channel", ch.ToString())); + } + if (filter.Kind is { } kind) + { + parts.Add(new("kind", kind.ToString())); + } + if (filter.Status is { } status) + { + parts.Add(new("status", status.ToString())); + } + if (!string.IsNullOrWhiteSpace(filter.SourceSiteId)) + { + parts.Add(new("site", filter.SourceSiteId)); + } + if (!string.IsNullOrWhiteSpace(filter.Target)) + { + parts.Add(new("target", filter.Target)); + } + if (!string.IsNullOrWhiteSpace(filter.Actor)) + { + parts.Add(new("actor", filter.Actor)); + } + if (filter.CorrelationId is { } corr) + { + parts.Add(new("correlationId", corr.ToString())); + } + if (filter.FromUtc is { } from) + { + parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); + } + if (filter.ToUtc is { } to) + { + parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture))); + } + + if (parts.Count == 0) + { + return basePath; + } + + return QueryHelpers.AddQueryString(basePath, parts); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor similarity index 98% rename from src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor rename to src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor index 96d7108..e7d76f4 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor @@ -1,14 +1,14 @@ -@page "/monitoring/audit-log" +@page "/audit/configuration" @using ScadaLink.Security @using ScadaLink.CentralUI.Components @using ScadaLink.Commons.Entities.Audit @using ScadaLink.Commons.Interfaces.Repositories -@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] @inject ICentralUiRepository CentralUiRepository @inject IJSRuntime JS
-

Audit Log

+

Configuration Audit Log

diff --git a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor index ce24f60..7935084 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor @@ -70,10 +70,10 @@
- +
-
Recent Audit Log
+
Configuration Audit Log

Browse changes to configuration and deployments.

diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index e3af35f..12aab37 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -22,6 +22,18 @@
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor index 8920202..0069cde 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor @@ -10,7 +10,20 @@
-

@(Id.HasValue ? "Edit External System" : "Add External System")

+
+

@(Id.HasValue ? "Edit External System" : "Add External System")

+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log + pre-filtered to this external system's outbound API events. Audit rows + record the target by external-system name, so we filter on Target. *@ + @if (Id.HasValue && !string.IsNullOrWhiteSpace(_name)) + { + + Recent audit activity + + } +
@if (_loading) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index bbff99b..58a87d1 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -1,5 +1,8 @@ @page "/monitoring/health" @attribute [Authorize] +@using ScadaLink.CentralUI.Components.Health +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Types @using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @@ -10,6 +13,7 @@ @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService +@inject IAuditLogQueryService AuditLogQueryService
@@ -56,6 +60,12 @@
Notification Outbox KPIs unavailable: @_outboxKpiError
} + @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel + (volume / error rate / backlog). Refreshed alongside the site states. *@ + + @if (_siteStates.Count == 0) {
No site health reports received yet.
@@ -347,6 +357,13 @@ private bool _outboxKpiAvailable; private string? _outboxKpiError; + // Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come + // from a 1h aggregate over the central AuditLog table; backlog sums the + // per-site SiteAuditBacklog.PendingCount via the health aggregator. + private AuditLogKpiSnapshot? _auditKpi; + private bool _auditKpiAvailable; + private string? _auditKpiError; + private static bool SiteHasActiveErrors(SiteHealthState state) { var report = state.LatestReport; @@ -384,6 +401,7 @@ { _siteStates = HealthAggregator.GetAllSiteStates(); await LoadOutboxKpis(); + await LoadAuditKpis(); } private async Task LoadOutboxKpis() @@ -416,6 +434,24 @@ private string OutboxTileValue(int value) => _outboxKpiAvailable ? value.ToString() : "—"; + // Audit KPI loader: wraps the service call so a transient DB outage degrades + // the three tiles to em dashes with an inline error rather than killing the + // dashboard. Mirrors LoadOutboxKpis's error handling shape. + private async Task LoadAuditKpis() + { + try + { + _auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync(); + _auditKpiAvailable = true; + _auditKpiError = null; + } + catch (Exception ex) + { + _auditKpiAvailable = false; + _auditKpiError = $"KPI query failed: {ex.Message}"; + } + } + private string GetSiteName(string siteId) { return _siteNames.GetValueOrDefault(siteId, siteId); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 1a66d3d..b083824 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -163,6 +163,14 @@ + @* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit + CorrelationId, so the link deep-links into the central Audit + Log pre-filtered to this notification's lifecycle events. *@ + + View audit history + @if (n.Status == "Parked") {