Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
2026-05-28 09:37:45 -04:00

392 lines
16 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)]
@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 (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)
{
<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-2">
<label class="form-label small" for="audit-filter-from">From</label>
<input id="audit-filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-to">To</label>
<input id="audit-filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div>
<div class="col-md-2 d-flex gap-1">
<button class="btn btn-primary btn-sm" @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 btn-sm" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</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">@entry.User</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">@entry.EntityName</td>
<td>
@if (hasState)
{
if (isLarge)
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
@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 py-0 px-1"
@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-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_page of @TotalPages (@_totalCount total)</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(!HasMore)">Next</button>
</div>
</div>
}
@if (_modalEntry != null)
{
<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 @_modalEntry.Id — @_modalEntry.EntityType state
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="bg-light 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>
/// T24 (Transport). 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;
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<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()
{
// 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;
}
}
}