Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
T

443 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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)]
@implements IAsyncDisposable
@inject ICentralUiRepository CentralUiRepository
@inject NavigationManager Nav
@inject IJSRuntime JS
<div class="container-fluid mt-3">
<h4 class="mb-3">Configuration Audit Log</h4>
<ToastNotification @ref="_toast" />
@* 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)
{
<div class="mb-3">
<span class="badge bg-primary p-2">
Filtered by Bundle Import: <code class="text-light">@bundleId.ToString()[..8]</code>
<button type="button"
class="btn-close btn-close-white btn-sm ms-2"
aria-label="Clear Bundle Import filter"
@onclick="ClearBundleImportFilter"></button>
</span>
</div>
}
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="audit-filter-user">User</label>
<input id="audit-filter-user"
type="text"
class="form-control form-control-sm"
aria-label="User"
@bind="_filterUser"
placeholder="Username" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-entity-type">Entity Type</label>
<input id="audit-filter-entity-type"
type="text"
class="form-control form-control-sm"
aria-label="Entity type"
@bind="_filterEntityType"
placeholder="e.g. Template" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-action">Action</label>
<input id="audit-filter-action"
type="text"
class="form-control form-control-sm"
aria-label="Action"
@bind="_filterAction"
placeholder="e.g. Create" />
</div>
<div class="col-md-4">
<DateTimeRangeFilter From="_filterFrom" FromChanged="(v => _filterFrom = v)"
To="_filterTo" ToChanged="(v => _filterTo = v)"
IdPrefix="audit-filter" />
</div>
<div class="col-md-2">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-primary" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</div>
</div>
</div>
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_entries != null)
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity Type</th>
<th>Entity ID</th>
<th>Entity Name</th>
<th>State</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No audit entries found.</td></tr>
}
@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;
<tr>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small"><span class="cell-clip cell-clip-sm" title="@entry.User">@entry.User</span></td>
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
<td class="small">@entry.EntityType</td>
<td class="small">
@if (!string.IsNullOrEmpty(entry.EntityId))
{
<code>@entityIdShort…</code>
<button class="btn btn-link btn-sm p-0 ms-1"
@onclick="() => CopyAsync(entry.EntityId)"
title="Copy entity ID"
aria-label="Copy entity ID @entry.EntityId">📋</button>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="small">
@* Entity names are operator/bundle supplied — guarded and
bounded exactly like the Entity ID neighbour above. *@
@if (!string.IsNullOrEmpty(entry.EntityName))
{
<span class="cell-clip" title="@entry.EntityName">@entry.EntityName</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (hasState)
{
if (isLarge)
{
<button class="btn btn-outline-info btn-sm"
@onclick="() => ShowStateModal(entry)"
aria-label="Open state details in modal for audit entry @entry.Id">
View in modal
</button>
}
else
{
<button class="btn btn-outline-info btn-sm"
@onclick="() => ToggleStateView(entry.Id)"
aria-label="Toggle state details for audit entry @entry.Id">
@(_expandedEntryId == entry.Id ? "Hide" : "View")
</button>
}
}
else
{
<span class="text-muted small">—</span>
}
</td>
</tr>
@if (hasState && !isLarge && _expandedEntryId == entry.Id)
{
<tr>
<td colspan="7">
<pre class="bg-body-secondary p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
</td>
</tr>
}
}
</tbody>
</table>
<OffsetPager Page="_page" PageChanged="OnPageChanged" HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />
}
@* 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();
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Audit entry @modalEntryId@(modalEntry is null ? "" : $" — {modalEntry.EntityType} state")
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
@if (modalEntry is null)
{
<div class="alert alert-secondary py-2 mb-0" data-test="modal-entry-gone">
This entry is no longer in the current page of results,
so its state can't be shown.
</div>
}
else
{
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(modalEntry.AfterStateJson!)</pre>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
</div>
</div>
</div>
</div>
}
</div>
@code {
/// <summary>
/// When non-null, scopes the page to a single bundle
/// import run. Set via the <c>?bundleImportId=</c> query string from
/// drill-ins (Import wizard summary, future BundleImported row links).
/// </summary>
[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<AuditLogEntry>? _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;
/// <summary>
/// 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.
/// </summary>
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<int>("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;
/// <summary>
/// Releases the dynamically imported browser-time module so its JS-side object reference
/// is not retained for the life of the circuit. A disconnected circuit has already
/// dropped it, so that failure is expected and swallowed.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_browserTimeModule is null) return;
try { await _browserTimeModule.DisposeAsync(); }
catch (JSDisconnectedException) { /* circuit already gone */ }
_browserTimeModule = null;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
// Date.getTimezoneOffset() returns (UTC - local) in minutes.
_browserTimeModule ??= await JS.InvokeAsync<IJSObjectReference>(
"import", BrowserTimeModulePath);
_browserUtcOffsetMinutes = await _browserTimeModule.InvokeAsync<int>(
"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();
}
// The state modal is page-scoped here: its content resolves from _entries alone
// (this surface has no keyed detail fetch), so an entry paged out of view can
// never resolve again and the modal would sit on a permanently empty notice.
// Clearing on an explicit navigation action is intent, not a resolve-driven
// unmount, so it does not reopen the handler-disposal race.
private async Task OnPageChanged(int page) { _page = page; _modalEntryId = null; 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;
}
}
}