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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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;
}
}
}