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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user