@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 (T24). 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 { } @entry.EntityName @if (hasState) { if (isLarge) { } else { } } else { }
@FormatJson(entry.AfterStateJson!)
Page @_page of @TotalPages (@_totalCount total)
} @if (_modalEntry != null) { }
@code { /// /// T24 (Transport). 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; private AuditLogEntry? _modalEntry; private ToastNotification _toast = default!; private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1; private bool HasMore => _page * _pageSize < _totalCount; // 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() { // T24: 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 PrevPage() { _page--; await FetchPage(); } private async Task NextPage() { _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) { _modalEntry = entry; } private void CloseStateModal() { _modalEntry = 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; } } }