@page "/audit/configuration" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.CentralUI.Components @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] @inject ICentralUiRepository CentralUiRepository @inject NavigationManager Nav @inject IJSRuntime JS

Configuration Audit Log

@* Bundle Import filter chip. Set via ?bundleImportId={guid} query string so drill-ins from the Import wizard / other pages can scope this page to a single import run. Cleared via the × button, which navigates back to the page without the query param so the user sees all rows. *@ @if (BundleImportId is Guid bundleId) {
Filtered by Bundle Import: @bundleId.ToString()[..8]
}
@if (_errorMessage != null) {
@_errorMessage
} @if (_entries != null) { @if (_entries.Count == 0) { } @foreach (var entry in _entries) { var entityIdShort = entry.EntityId is { Length: > 0 } ? entry.EntityId[..Math.Min(12, entry.EntityId.Length)] : ""; var hasState = !string.IsNullOrWhiteSpace(entry.AfterStateJson); var isLarge = hasState && entry.AfterStateJson!.Length > 1024; @if (hasState && !isLarge && _expandedEntryId == entry.Id) { } }
Timestamp User Action Entity Type Entity ID Entity Name State
No audit entries found.
@entry.User @entry.Action @entry.EntityType @if (!string.IsNullOrEmpty(entry.EntityId)) { @entityIdShort… } else { } @* Entity names are operator/bundle supplied — guarded and bounded exactly like the Entity ID neighbour above. *@ @if (!string.IsNullOrEmpty(entry.EntityName)) { @entry.EntityName } else { } @if (hasState) { if (isLarge) { } else { } } else { }
@FormatJson(entry.AfterStateJson!)
} @* The state modal holds only the entry's id and re-resolves the row from the page currently on screen on every render (ModalEntry()), so it never renders a stale record after a refetch replaces the entry list. Visibility is gated on _modalEntryId — user intent — NOT on the resolve succeeding. Gating the subtree on ModalEntry() would make the modal's existence a function of list contents, so a render where the entry is momentarily absent unmounts the subtree and disposes the event-handler ids inside it, Close included; a click already in flight against a disposed handler throws GetRequiredEventBindingEntry. Only the user closes this. *@ @if (_modalEntryId is { } modalEntryId) { var modalEntry = ModalEntry(); }
@code { /// /// When non-null, scopes the page to a single bundle /// import run. Set via the ?bundleImportId= query string from /// drill-ins (Import wizard summary, future BundleImported row links). /// [SupplyParameterFromQuery, Parameter] public Guid? BundleImportId { get; set; } private string? _filterUser; private string? _filterEntityType; private string? _filterAction; private DateTime? _filterFrom; private DateTime? _filterTo; // The datetime-local filter inputs are in the browser's local time zone. // This holds new Date().getTimezoneOffset() so the values are converted to // UTC (CentralUI-008) rather than relabelled. Until JS interop runs it is 0 // (UTC), which is a safe default for a UTC server/browser. private int _browserUtcOffsetMinutes; private List? _entries; private int _totalCount; private int _page = 1; private int _pageSize = 50; private bool _searching; private string? _errorMessage; private int? _expandedEntryId; // State modal. Only the entry's IDENTIFIER is held — never the AuditLogEntry // record itself — so the modal cannot outlive the row it was opened for. // ModalEntry() re-resolves it from the page currently on screen on every // render; an entry that has left the page resolves to null and the modal // renders nothing (it self-closes) rather than showing a stale snapshot. private int? _modalEntryId; private ToastNotification _toast = default!; private bool HasMore => _page * _pageSize < _totalCount; /// /// Resolves the entry the state modal is open for from the page currently on /// screen. Returns null when no entry is selected, when the list has not /// loaded, or when the selected entry is no longer in the page — the modal's /// visibility gate. /// private AuditLogEntry? ModalEntry() => _modalEntryId is { } id ? _entries?.FirstOrDefault(e => e.Id == id) : null; // Tracks the BundleImportId we last fetched against so a re-render with the // same query param doesn't re-run the query on every parameter set. private Guid? _lastFetchedBundleImportId; // CentralUI-029: the browser-time JS module that hosts getTimezoneOffsetMinutes(). // Loaded lazily on first render via dynamic import; replaces the previous // `JS.InvokeAsync("eval", "new Date().getTimezoneOffset()")` call, which // widened the JS-interop attack surface and was incompatible with strict CSP // `script-src` directives that forbid `unsafe-eval`. private const string BrowserTimeModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/browser-time.js"; private IJSObjectReference? _browserTimeModule; protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; try { // Date.getTimezoneOffset() returns (UTC - local) in minutes. _browserTimeModule ??= await JS.InvokeAsync( "import", BrowserTimeModulePath); _browserUtcOffsetMinutes = await _browserTimeModule.InvokeAsync( "getTimezoneOffsetMinutes"); } catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException or TaskCanceledException) { // Prerender or a disconnected circuit: fall back to UTC (offset 0). _browserUtcOffsetMinutes = 0; } } protected override async Task OnParametersSetAsync() { // When the BundleImportId query param is set (or cleared), refetch // automatically so the user lands on a pre-filtered page from a drill-in // link without having to click Search. if (BundleImportId != _lastFetchedBundleImportId) { _lastFetchedBundleImportId = BundleImportId; _page = 1; await FetchPage(); } } private void ClearBundleImportFilter() { // Strip the query param by navigating to the bare page route. The // resulting OnParametersSetAsync run will refetch with BundleImportId // back to null. Nav.NavigateTo("/audit/configuration"); } private async Task Search() { _page = 1; await FetchPage(); } private async Task ClearFilters() { _filterUser = null; _filterEntityType = null; _filterAction = null; _filterFrom = null; _filterTo = null; _page = 1; await FetchPage(); } private async Task OnPageChanged(int page) { _page = page; await FetchPage(); } private async Task FetchPage() { _searching = true; _errorMessage = null; try { var (entries, totalCount) = await CentralUiRepository.GetAuditLogEntriesAsync( user: string.IsNullOrWhiteSpace(_filterUser) ? null : _filterUser.Trim(), entityType: string.IsNullOrWhiteSpace(_filterEntityType) ? null : _filterEntityType.Trim(), action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(), from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes), to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes), bundleImportId: BundleImportId, page: _page, pageSize: _pageSize); _entries = entries.ToList(); _totalCount = totalCount; } catch (Exception ex) { _errorMessage = $"Query failed: {ex.Message}"; } _searching = false; } private void ToggleStateView(int entryId) { _expandedEntryId = _expandedEntryId == entryId ? null : entryId; } private void ShowStateModal(AuditLogEntry entry) { _modalEntryId = entry.Id; } private void CloseStateModal() { _modalEntryId = null; } private async Task CopyAsync(string text) { try { await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); _toast.ShowSuccess("Copied to clipboard."); } catch { _toast.ShowError("Copy failed."); } } private static string GetActionBadge(string action) => action switch { "Create" => "bg-success", "Update" => "bg-primary", "Delete" => "bg-danger", "Deploy" => "bg-info text-dark", _ => "bg-secondary" }; private static string FormatJson(string json) { try { var doc = System.Text.Json.JsonDocument.Parse(json); return System.Text.Json.JsonSerializer.Serialize(doc, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } catch { return json; } } }