443 lines
18 KiB
Plaintext
443 lines
18 KiB
Plaintext
@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;
|
||
}
|
||
}
|
||
}
|