d14e0ee4b1
The sweep's modal re-key (holding the row's id and re-resolving it, rather than holding the record) also used that resolve as the modal's visibility gate. That makes the modal's existence a function of list contents: any render where the row is momentarily unresolvable unmounts the whole subtree and disposes every event-handler id inside it, Close's included. A click already in flight against a disposed handler makes the renderer throw GetRequiredEventBindingEntry during DispatchEventAsync — which is how this surfaced, as an intermittent failure of CloseButton_DismissesModal (989/990 on one run, green on re-run). The record-held form made that structurally impossible: the modal existed because the user opened it, and no list mutation could retract that. This restores the property while keeping the re-key's actual benefit. Visibility now gates on the held id; the resolve drives only content. An unresolvable row degrades to an explicit notice and hides the row-scoped actions, while the frame and Close stay mounted. Detail fetched by id still renders, so the user does not lose the body they opened. Applied to all four surfaces that shared the construction: NotificationReport, ConfigurationAuditLog, ParkedMessages (offcanvas drawer) and SiteCallsReport. Modal_StaysOpen_WhenItsRowLeavesThePage drops the opened row from the next query and asserts the modal survives, keeps its fetched body, hides Retry/Discard, and that Close still works. It was run against a deliberately restored defective gate and failed there before passing here — a regression test that passes both ways would be worthless against a race. 20 consecutive runs of the previously flaky class: no failures. CentralUI.Tests 991/991, solution build 0/0. The plan doc gains a section recording that the sweep was reported as behaviour-preserving when it was not, and why the merge review missed it.
424 lines
17 KiB
Plaintext
424 lines
17 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)]
|
||
@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;
|
||
|
||
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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|