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.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
@page "/audit/log"
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
<PageTitle>Audit Log</PageTitle>
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h1 class="h4 mb-3">Audit Log</h1>
|
||||
|
||||
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid.
|
||||
Bundle D (M7-T10..T12) threads a query-string instance prefill through
|
||||
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
|
||||
<div class="mb-3">
|
||||
<AuditFilterBar OnFilterChanged="HandleFilterChanged"
|
||||
InitialInstanceSearch="@_initialInstanceSearch" />
|
||||
</div>
|
||||
|
||||
@* Export button (Bundle F / M7-T14). A plain <a download> link triggers the
|
||||
streaming CSV endpoint at /api/centralui/audit/export — chosen over a
|
||||
SignalR-driven download because the request can stream 100k rows directly
|
||||
to the response body without buffering through the Blazor circuit. The
|
||||
href reflects the most recently applied filter; before Apply is clicked,
|
||||
an unconstrained export is exposed.
|
||||
|
||||
Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an
|
||||
OperationalAudit-only operator (read access without bulk export) sees the
|
||||
page + filters but cannot trigger the CSV pull. The endpoint itself is
|
||||
gated separately, so a hand-crafted URL still 403s — the AuthorizeView
|
||||
here is the user-facing affordance, not the authoritative check. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.AuditExport">
|
||||
<Authorized Context="exportContext">
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="@ExportUrl"
|
||||
download
|
||||
role="button"
|
||||
aria-label="Export current view to CSV">
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
|
||||
drilldown drawer; the grid stays in "no events" mode until the user applies a
|
||||
filter so the page does not auto-load the full audit table on first render. *@
|
||||
<div>
|
||||
<AuditResultsGrid Filter="@_currentFilter" OnRowSelected="HandleRowSelected" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Drilldown drawer (Bundle C / M7-T4..T8). Hosted at the page level so the
|
||||
off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@
|
||||
<AuditDrilldownDrawer Event="@_selectedEvent"
|
||||
IsOpen="@_drawerOpen"
|
||||
OnClose="HandleDrawerClose" />
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
|
||||
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
|
||||
/// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the
|
||||
/// grid on every Apply (the grid uses reference identity as its "reload"
|
||||
/// trigger). Row clicks land in <see cref="HandleRowSelected"/> — Bundle C wires
|
||||
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
|
||||
/// not error.
|
||||
///
|
||||
/// <para>
|
||||
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
||||
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
||||
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
|
||||
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
|
||||
/// "View parent execution" drill-in. When any param is present we allocate a
|
||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
||||
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
||||
/// are silently dropped — the page still renders, just without that constraint.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Query-string filters are re-applied on every <see cref="NavigationManager.LocationChanged"/>,
|
||||
/// not just on init. The drilldown drawer's "View this/parent execution" actions
|
||||
/// navigate to <c>/audit/log?executionId=…</c> while the user is ALREADY on this
|
||||
/// routed page — Blazor treats that as a same-component navigation, so
|
||||
/// <see cref="OnInitialized"/> does not re-run. Without the
|
||||
/// <see cref="NavigationManager.LocationChanged"/> subscription the URL would
|
||||
/// change but <see cref="_currentFilter"/> would stay stale and the grid would
|
||||
/// never reload to the new drill-in. The subscription is disposed via
|
||||
/// <see cref="IDisposable"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AuditLogPage : IDisposable
|
||||
{
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
private AuditLogQueryFilter? _currentFilter;
|
||||
private AuditEvent? _selectedEvent;
|
||||
private bool _drawerOpen;
|
||||
private string? _initialInstanceSearch;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ApplyQueryStringFilters();
|
||||
Navigation.LocationChanged += HandleLocationChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the query-string drill-in filters when the URL changes while
|
||||
/// this page stays routed (e.g. the drawer's "View parent execution" action
|
||||
/// navigates to <c>/audit/log?executionId=…</c>). Reassigning
|
||||
/// <see cref="_currentFilter"/> to a fresh instance is what kicks the results
|
||||
/// grid into reloading; we also close the drawer so the operator sees the
|
||||
/// newly filtered grid. The body is marshalled through
|
||||
/// <see cref="ComponentBase.InvokeAsync(Action)"/> because
|
||||
/// <see cref="NavigationManager.LocationChanged"/> can fire off the renderer's
|
||||
/// synchronization context.
|
||||
/// </summary>
|
||||
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
_ = InvokeAsync(() =>
|
||||
{
|
||||
ApplyQueryStringFilters();
|
||||
_drawerOpen = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from navigation events to prevent memory leaks when the component is removed.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= HandleLocationChanged;
|
||||
}
|
||||
|
||||
private void ApplyQueryStringFilters()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
|
||||
// A paramless navigation (e.g. clicking the "Audit Log" nav link while
|
||||
// already here) intentionally preserves the last applied filter rather
|
||||
// than clearing the grid: this method is a drill-in mechanism and every
|
||||
// drill-in carries query params. The operator clears via the filter bar.
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? correlationId = null;
|
||||
if (query.TryGetValue("correlationId", out var corrValues)
|
||||
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
|
||||
{
|
||||
correlationId = parsedCorr;
|
||||
}
|
||||
|
||||
// ?executionId= is the "View this execution" drill-in target — the
|
||||
// universal per-run correlation value. Lax-parsed like ?correlationId=:
|
||||
// an unparseable value is silently dropped (no constraint).
|
||||
Guid? executionId = null;
|
||||
if (query.TryGetValue("executionId", out var execValues)
|
||||
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||
{
|
||||
executionId = parsedExec;
|
||||
}
|
||||
|
||||
// ?parentExecutionId= constrains to runs spawned by a given execution.
|
||||
// Lax-parsed like ?executionId=: an unparseable value is silently dropped.
|
||||
Guid? parentExecutionId = null;
|
||||
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||
{
|
||||
parentExecutionId = parsedParentExec;
|
||||
}
|
||||
|
||||
string? target = null;
|
||||
if (query.TryGetValue("target", out var targetValues))
|
||||
{
|
||||
var v = targetValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
target = v.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
string? actor = null;
|
||||
if (query.TryGetValue("actor", out var actorValues))
|
||||
{
|
||||
var v = actorValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
actor = v.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// site/channel/kind/status accept repeated params for symmetry with the
|
||||
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
|
||||
// drill-in still works (one-element list). Unknown enum names are silently
|
||||
// dropped. The lax-parse contract is shared with the two export endpoints
|
||||
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
|
||||
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
|
||||
|
||||
IReadOnlyList<AuditChannel>? channels =
|
||||
AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
|
||||
|
||||
// ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
|
||||
// param — a kind drill-in deep link must round-trip back into the filter.
|
||||
IReadOnlyList<AuditKind>? kinds =
|
||||
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
|
||||
|
||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
|
||||
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
|
||||
// Unknown values are silently dropped — the page still renders without
|
||||
// the constraint.
|
||||
IReadOnlyList<AuditStatus>? statuses =
|
||||
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
|
||||
|
||||
// Instance is UI-only — the filter contract has no matching column, so we
|
||||
// pass it as a separate seam to the filter bar.
|
||||
if (query.TryGetValue("instance", out var instanceValues))
|
||||
{
|
||||
var v = instanceValues.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
_initialInstanceSearch = v.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If ANY filter-shaped param was provided, allocate the filter so the grid
|
||||
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
||||
// because the filter contract has no instance column — the user still needs
|
||||
// to refine + Apply for those.
|
||||
if (correlationId is null && executionId is null && parentExecutionId is null
|
||||
&& target is null && actor is null
|
||||
&& sites is null && channels is null && kinds is null && statuses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentFilter = new AuditLogQueryFilter(
|
||||
Channels: channels,
|
||||
Kinds: kinds,
|
||||
Statuses: statuses,
|
||||
SourceSiteIds: sites,
|
||||
Target: target,
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
ExecutionId: executionId,
|
||||
ParentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the raw repeated values for one query-string key, returning
|
||||
/// <c>null</c> when the key is absent so the shared
|
||||
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
|
||||
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
|
||||
/// <c>StringValues</c> is itself an <c>IEnumerable<string?></c>.
|
||||
/// </summary>
|
||||
private static IEnumerable<string?>? Raw(
|
||||
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
|
||||
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
|
||||
|
||||
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
||||
{
|
||||
// Always reassign — the grid keys reloads on reference change, so even a
|
||||
// chip-for-chip identical filter must allocate a fresh instance.
|
||||
_currentFilter = filter;
|
||||
}
|
||||
|
||||
private void HandleRowSelected(AuditEvent row)
|
||||
{
|
||||
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
|
||||
// the selected row and open the drilldown drawer — the drawer is fully
|
||||
// presentational so we do not need to refetch the row.
|
||||
_selectedEvent = row;
|
||||
_drawerOpen = true;
|
||||
}
|
||||
|
||||
private void HandleDrawerClose()
|
||||
{
|
||||
// We deliberately keep _selectedEvent set so re-opening (e.g. via the
|
||||
// grid) shows the same row instantly without a re-render flicker.
|
||||
_drawerOpen = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
|
||||
/// recently applied filter as query-string params so the server-side
|
||||
/// streaming endpoint reproduces the user's current view. With no filter
|
||||
/// applied yet, returns the bare endpoint — i.e. an unconstrained export.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built here rather than in markup so the per-row test coverage can
|
||||
/// exercise the URL composition without booting the full Blazor renderer.
|
||||
/// </remarks>
|
||||
internal string ExportUrl => BuildExportUrl(_currentFilter);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters.
|
||||
/// </summary>
|
||||
/// <param name="filter">Currently applied filter; null returns the bare export endpoint.</param>
|
||||
internal static string BuildExportUrl(AuditLogQueryFilter? filter)
|
||||
{
|
||||
const string basePath = "/api/centralui/audit/export";
|
||||
if (filter is null)
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
// No capacity hint: the dimensions are multi-value, so the part count is
|
||||
// unbounded by the number of filter fields.
|
||||
var parts = new List<KeyValuePair<string, string?>>();
|
||||
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
|
||||
// repeated query-string key per selected value (channel=A&channel=B); the
|
||||
// export endpoint's ParseFilter reads the full repeated set.
|
||||
if (filter.Channels is { Count: > 0 } channels)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
parts.Add(new("channel", channel.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.Kinds is { Count: > 0 } kinds)
|
||||
{
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
parts.Add(new("kind", kind.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.Statuses is { Count: > 0 } statuses)
|
||||
{
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
parts.Add(new("status", status.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
|
||||
{
|
||||
foreach (var site in sourceSiteIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(site))
|
||||
{
|
||||
parts.Add(new("site", site));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Target))
|
||||
{
|
||||
parts.Add(new("target", filter.Target));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Actor))
|
||||
{
|
||||
parts.Add(new("actor", filter.Actor));
|
||||
}
|
||||
if (filter.CorrelationId is { } corr)
|
||||
{
|
||||
parts.Add(new("correlationId", corr.ToString()));
|
||||
}
|
||||
if (filter.ExecutionId is { } exec)
|
||||
{
|
||||
parts.Add(new("executionId", exec.ToString()));
|
||||
}
|
||||
if (filter.ParentExecutionId is { } parentExec)
|
||||
{
|
||||
parts.Add(new("parentExecutionId", parentExec.ToString()));
|
||||
}
|
||||
if (filter.FromUtc is { } from)
|
||||
{
|
||||
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
if (filter.ToUtc is { } to)
|
||||
{
|
||||
parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
return QueryHelpers.AddQueryString(basePath, parts);
|
||||
}
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page "/audit/execution-tree"
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
<PageTitle>Execution Chain</PageTitle>
|
||||
|
||||
@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10).
|
||||
A drill-in target reached from the Audit Log drawer's "View execution chain"
|
||||
action: /audit/execution-tree?executionId={guid}. The page parses the id,
|
||||
asks the query service for the whole chain (flat ExecutionTreeNode list), and
|
||||
hands it to the recursive ExecutionTree component. There is deliberately NO
|
||||
nav-menu entry — this page is only meaningful in the context of a specific
|
||||
execution, so it is reachable only via drill-in (the Audit nav group keeps
|
||||
just the Audit Log + Configuration Audit Log pages). *@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h1 class="h4 mb-1">Execution Chain</h1>
|
||||
<p class="text-muted small mb-3">
|
||||
The full chain of script / inbound-request executions linked by
|
||||
<span class="font-monospace">ParentExecutionId</span>, rooted at the
|
||||
topmost ancestor. Select an execution to open the Audit Log filtered to
|
||||
its rows.
|
||||
</p>
|
||||
|
||||
@if (_executionId is null)
|
||||
{
|
||||
@* No (or unparseable) ?executionId= — render guidance rather than an
|
||||
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
|
||||
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
|
||||
No execution selected. Open this view from an audit row's
|
||||
<strong>View execution chain</strong> action.
|
||||
</div>
|
||||
}
|
||||
else if (_loading)
|
||||
{
|
||||
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
|
||||
}
|
||||
else if (_nodes is { Count: > 0 })
|
||||
{
|
||||
<div class="mb-2">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
data-test="execution-tree-back-to-log"
|
||||
href="@($"/audit/log?executionId={_executionId}")">
|
||||
View this execution in the Audit Log
|
||||
</a>
|
||||
</div>
|
||||
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value"
|
||||
OnNodeActivated="HandleNodeActivated" />
|
||||
|
||||
@* Double-clicking a tree node raises OnNodeActivated, which opens this
|
||||
modal for that execution. The modal renders nothing while IsOpen is
|
||||
false, so it is safe to place unconditionally here. *@
|
||||
<ExecutionDetailModal ExecutionId="_modalExecutionId" IsOpen="_modalOpen"
|
||||
OnClose="HandleModalClose" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-secondary small" data-test="execution-tree-empty">
|
||||
No execution chain found for this id.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
|
||||
/// Log drilldown drawer's "View execution chain" action with
|
||||
/// <c>?executionId={guid}</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// On initialization the page parses <c>?executionId=</c> (lax-parsed, matching
|
||||
/// the Audit Log page's drill-in contract — an absent or unparseable value
|
||||
/// leaves the page in a guidance state and issues NO service call), then asks
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
|
||||
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
|
||||
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
|
||||
/// the tree.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
|
||||
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
|
||||
/// directly, so the page can be unit-tested with a substituted service.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class ExecutionTreePage
|
||||
{
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
// The parsed ?executionId= value, or null when absent / unparseable.
|
||||
private Guid? _executionId;
|
||||
|
||||
// The flat chain returned by the query service; null until the load
|
||||
// completes (or when no id was supplied).
|
||||
private IReadOnlyList<ExecutionTreeNode>? _nodes;
|
||||
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
|
||||
// Execution-Tree Node Detail Modal feature (Task 4) — state backing the
|
||||
// <ExecutionDetailModal>. A double-click on a tree node sets
|
||||
// _modalExecutionId + flips _modalOpen true; the modal loads that
|
||||
// execution's audit rows on the closed → open transition. _modalOpen is the
|
||||
// visibility gate — _modalExecutionId is left intact across a close (it is
|
||||
// harmless while the modal is hidden and avoids a flicker if reopened).
|
||||
private Guid? _modalExecutionId;
|
||||
private bool _modalOpen;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_executionId = ParseExecutionId();
|
||||
if (_executionId is null)
|
||||
{
|
||||
// No id — render guidance, do not touch the service.
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadChainAsync(_executionId.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
|
||||
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
|
||||
/// of an error, consistent with the Audit Log page's drill-in handling.
|
||||
/// </summary>
|
||||
private Guid? ParseExecutionId()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (query.TryGetValue("executionId", out var values)
|
||||
&& Guid.TryParse(values.ToString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task LoadChainAsync(Guid executionId)
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
_nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A transient DB outage degrades this page to an error banner
|
||||
// rather than killing the circuit — the same defensive posture the
|
||||
// Audit Log grid takes around its query.
|
||||
_error = $"Could not load the execution chain: {ex.Message}";
|
||||
_nodes = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised by <c>ExecutionTree</c> (bubbled up from a node double-click) with
|
||||
/// the activated node's <c>ExecutionId</c>. Opens the
|
||||
/// <c>ExecutionDetailModal</c> for that execution — the modal loads its
|
||||
/// audit rows on the closed → open transition.
|
||||
/// </summary>
|
||||
private void HandleNodeActivated(Guid executionId)
|
||||
{
|
||||
_modalExecutionId = executionId;
|
||||
_modalOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised by <c>ExecutionDetailModal</c> when the user dismisses it. Flips
|
||||
/// the visibility gate closed; <see cref="_modalExecutionId"/> is left as-is.
|
||||
/// </summary>
|
||||
private void HandleModalClose() => _modalOpen = false;
|
||||
}
|
||||
Reference in New Issue
Block a user