Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -1,10 +1,9 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Security
@inject IManualFailoverService Failover
@inject AuthorizedManualFailoverService Failover
@inject IDialogService Dialog
@* Admin-only manual failover of the central pair (decision 2026-07-22). Rendered on the
@@ -94,11 +93,10 @@
try
{
var actor = await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
if (IsSite)
{
var outcome = await Failover.FailOverSiteAsync(SiteId!, actor);
var outcome = await Failover.FailOverSiteAsync(SiteId!);
if (outcome.Accepted)
{
_message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster.";
@@ -113,7 +111,7 @@
}
else
{
var target = await Failover.FailOverCentralAsync(actor);
var target = await Failover.FailOverCentralAsync();
if (target is null)
{
// The server-side peer guard refused. Never report a failover that did not happen.
@@ -47,33 +47,38 @@
<NavRailItem Href="/deployment/topology" Text="Topology" />
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
</NavRailSection>
</Authorized>
</AuthorizeView>
@* Notifications — mixed-role section; each item gated by its own policy.
The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@
<NavRailSection Title="Notifications" Key="notifications">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext">
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
<NavRailItem Href="/notifications/sms" Text="SMS Configuration" />
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="notifDesignContext">
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="notifDeploymentContext">
<NavRailItem Href="/notifications/report" Text="Notification Report" />
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
</Authorized>
</AuthorizeView>
</NavRailSection>
<AuthorizeView Policy="@AuthorizationPolicies.RequireFlowDebugger">
<Authorized Context="flowContext">
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
</Authorized>
</AuthorizeView>
@if (context.User.Claims.Any(c => c.Type == JwtTokenService.RoleClaimType && (c.Value == Roles.Administrator || c.Value == Roles.Designer || c.Value == Roles.Deployer)))
{
<NavRailSection Title="Notifications" Key="notifications">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext">
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
<NavRailItem Href="/notifications/sms" Text="SMS Configuration" />
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="notifDesignContext">
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="notifDeploymentContext">
<NavRailItem Href="/notifications/report" Text="Notification Report" />
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
</Authorized>
</AuthorizeView>
</NavRailSection>
}
@* Site Calls — Site Call Audit (#22). Deployer-role only,
matching the Notification Report page's gate; the whole
@@ -704,12 +704,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes the fresh copy.</summary>
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
var byId = new Dictionary<Guid, AuditEvent>();
foreach (var e in known ?? []) byId[e.EventId] = e;
foreach (var e in fresh ?? []) byId[e.EventId] = e;
foreach (var e in known ?? []) byId.TryAdd(e.EventId, e);
return byId.Values.ToList();
}
@@ -811,11 +811,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>
/// Server-side re-check before relaying — the action must not fire for a site outside the caller's
/// permitted set even though the node is on screen. Notifications are central-owned (no site).
/// A site the page cannot resolve is allowed through, as on the Site Calls page.
/// An unresolvable site is denied for a scoped caller.
/// </summary>
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
{
if (step.Kind == FlowStepKind.Notify || string.IsNullOrEmpty(step.SiteId))
if (step.Kind == FlowStepKind.Notify)
return true;
if (await SiteScope.IsSystemWideAsync())
return true;
@@ -832,7 +832,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
}
if (site is null)
return true;
return false;
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
}
@@ -73,7 +73,7 @@
</div>
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..."
placeholder="Search loaded sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm" role="group" aria-label="Topology actions">
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
@@ -84,7 +84,7 @@
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
<button class="btn btn-outline-secondary btn-sm" aria-label="Re-check staleness"
title="Recompute the Stale/Current badge for every deployed instance"
title="Recompute the Stale/Current badge for loaded deployed instances"
@onclick="RecheckStalenessAsync" disabled="@_recheckingStaleness">
@if (_recheckingStaleness)
{
@@ -104,7 +104,7 @@
</div>
<div class="alert alert-light py-2 mb-3 small">
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
@_allAreas.Count loaded area(s) · @_allInstances.Count loaded instance(s) across @_sites.Count site(s). Expand a branch and load its instances (50 per page).
</div>
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
@@ -136,7 +136,11 @@
// ---- Data ----
private List<Instance> _allInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private const int InstancePageSize = 50;
private readonly Dictionary<(int Site, int? Area), int> _loadedPages = new();
private readonly HashSet<int> _loadedSites = new();
private bool _branchLoading;
private readonly SemaphoreSlim _loadGate = new(1, 1);
private List<Area> _allAreas = new();
private Dictionary<int, bool> _stalenessMap = new();
@@ -183,7 +187,7 @@
private const string SelectedKeyStorage = "topology-tree-selected";
// ---- Tree model ----
private enum TopoNodeKind { Site, Area, Instance }
private enum TopoNodeKind { Site, Area, Instance, Loader }
private record TopoNode(
string Key,
@@ -330,45 +334,81 @@
}
/// <summary>
/// Fast path — loads the cheap deployed state (sites, instances, areas →
/// hierarchy + State badge) WITHOUT recomputing per-instance staleness. These
/// are all single, indexed central-DB reads. This is exactly what the
/// live-update timer polls every 15s. Internal so the live-update regression
/// Refreshes permitted sites and previously loaded branch pages without expanding
/// new sites or loading every instance. Each instance query is capped at 50 rows.
/// This is the live-update timer path; it does not recompute staleness. Internal so the live-update regression
/// test can drive the timer's code path directly.
/// </summary>
internal async Task LoadDeployedStateAsync()
{
_loading = true;
_errorMessage = null;
await _loadGate.WaitAsync();
try
{
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
// sites — and therefore the areas/instances — they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
.Where(i => permittedSiteIds.Contains(i.SiteId))
.ToList();
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
_loading = true;
_errorMessage = null;
try
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
// sites — and therefore the areas/instances — they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
_loadedSites.IntersectWith(permittedSiteIds);
foreach (var key in _loadedPages.Keys.Where(k => !permittedSiteIds.Contains(k.Site)).ToArray())
_loadedPages.Remove(key);
_allInstances.Clear();
_allAreas.Clear();
foreach (var siteId in _loadedSites)
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
foreach (var (key, offset) in _loadedPages)
_allInstances.AddRange(await TemplateEngineRepository.GetTopologyInstancesAsync(key.Site, key.Area, offset, InstancePageSize));
BuildTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load topology: {ex.Message}";
}
_loading = false;
BuildTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load topology: {ex.Message}";
}
_loading = false;
finally { _loadGate.Release(); }
}
// Explicit branch loading keeps collapsed sites/areas out of circuit memory.
internal async Task LoadBranchAsync(int siteId, int? areaId, int offset = 0)
{
await _loadGate.WaitAsync();
try
{
if (!await SiteScope.IsSiteAllowedAsync(siteId)) return;
_branchLoading = true;
try
{
if (!_loadedSites.Contains(siteId))
{
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
_loadedSites.Add(siteId);
}
var rows = await TemplateEngineRepository.GetTopologyInstancesAsync(siteId, areaId, offset, InstancePageSize);
_allInstances.RemoveAll(i => i.SiteId == siteId && i.AreaId == areaId);
_allInstances.AddRange(rows);
_loadedPages[(siteId, areaId)] = offset;
await ComputeStalenessAsync(loadGateHeld: true);
BuildTree();
}
catch (Exception ex) { _errorMessage = $"Failed to load branch: {ex.Message}"; }
finally { _branchLoading = false; }
}
finally { _loadGate.Release(); }
}
private TopoNode MakeLoader(int siteId, int? areaId) => new(
$"load:{siteId}:{areaId}", TopoNodeKind.Loader, areaId ?? 0, siteId,
"Load instances", null, null, null, false, false, new());
/// <summary>
/// Expensive path — recomputes the Stale/Current badge for every deployed
/// Expensive path — recomputes the Stale/Current badge for each loaded deployed
/// instance via <c>DeploymentService.GetDeploymentComparisonAsync</c>, which
/// re-flattens each instance's current config and compares it to the deployed
/// snapshot. This is the page's dominant cost, so it runs only on initial
@@ -378,27 +418,33 @@
/// DI scope so the parallel flattens never share the circuit-scoped DbContext
/// (mirrors KpiHistoryQueryService's per-query scope).
/// </summary>
private async Task ComputeStalenessAsync()
private async Task ComputeStalenessAsync(bool loadGateHeld = false)
{
var deployedIds = _allInstances
.Where(i => i.State != InstanceState.NotDeployed)
.Select(i => i.Id)
.ToList();
if (deployedIds.Count == 0)
if (!loadGateHeld) await _loadGate.WaitAsync();
try
{
_stalenessMap = new Dictionary<int, bool>();
var deployedIds = _allInstances
.Where(i => i.State != InstanceState.NotDeployed)
.Select(i => i.Id)
.ToList();
if (deployedIds.Count == 0)
{
_stalenessMap = new Dictionary<int, bool>();
BuildTree();
return;
}
var results = new ConcurrentDictionary<int, bool>();
using var gate = new SemaphoreSlim(MaxConcurrentStalenessChecks, MaxConcurrentStalenessChecks);
await Task.WhenAll(deployedIds.Select(id => ComputeOneStalenessAsync(id, gate, results)));
_stalenessMap = new Dictionary<int, bool>(results);
BuildTree();
return;
}
var results = new ConcurrentDictionary<int, bool>();
using var gate = new SemaphoreSlim(MaxConcurrentStalenessChecks, MaxConcurrentStalenessChecks);
await Task.WhenAll(deployedIds.Select(id => ComputeOneStalenessAsync(id, gate, results)));
_stalenessMap = new Dictionary<int, bool>(results);
BuildTree();
finally { if (!loadGateHeld) _loadGate.Release(); }
}
private async Task ComputeOneStalenessAsync(
@@ -469,7 +515,7 @@
.Select(MakeInstanceNode)
.ToList();
var children = areaChildren.Concat(rootInstances).ToList();
var children = areaChildren.Concat(rootInstances).Append(MakeLoader(site.Id, null)).ToList();
return new TopoNode(
Key: $"s:{site.Id}",
@@ -500,7 +546,7 @@
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
var children = childAreas.Concat(areaInstances).Append(MakeLoader(area.SiteId, area.Id)).ToList();
return new TopoNode(
Key: $"a:{area.Id}",
Kind: TopoNodeKind.Area,
@@ -538,6 +584,21 @@
switch (node.Kind)
{
case TopoNodeKind.Loader:
var areaId = node.EntityId == 0 ? (int?)null : node.EntityId;
var loaded = _loadedPages.TryGetValue((node.SiteId, areaId), out var offset);
@if (!loaded)
{
<button class="btn btn-link btn-sm" disabled="@_branchLoading" @onclick="() => LoadBranchAsync(node.SiteId, areaId)">Load instances</button>
}
else
{
<span class="small text-muted">Page @(offset / InstancePageSize + 1)</span>
<button class="btn btn-link btn-sm" disabled="@(_branchLoading || offset == 0)" @onclick="() => LoadBranchAsync(node.SiteId, areaId, Math.Max(0, offset - InstancePageSize))">Previous</button>
<button class="btn btn-link btn-sm" disabled="@(_branchLoading || _allInstances.Count(i => i.SiteId == node.SiteId && i.AreaId == areaId) < InstancePageSize)" @onclick="() => LoadBranchAsync(node.SiteId, areaId, offset + InstancePageSize)">Next</button>
}
break;
case TopoNodeKind.Site:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
@@ -396,19 +396,12 @@
StopTimer();
_refreshTimer = new Timer(_ =>
{
if (!_pollGate.TryEnter()) return;
InvokeAsync(async () =>
_ = _pollGate.RunAsync(() => InvokeAsync(async () =>
{
try
{
await RefreshAsync();
StateHasChanged();
}
finally
{
_pollGate.Exit();
}
});
if (_disposed) return;
await RefreshAsync();
StateHasChanged();
}), error => System.Diagnostics.Trace.TraceWarning("AlarmSummary refresh failed: {0}", error));
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
@@ -718,19 +718,12 @@
_refreshTimer = new Timer(_ =>
{
if (!_pollGate.TryEnter()) return;
InvokeAsync(async () =>
_ = _pollGate.RunAsync(() => InvokeAsync(async () =>
{
try
{
await RefreshAllAsync(forceRefresh: false);
StateHasChanged();
}
finally
{
_pollGate.Exit();
}
});
if (_disposed) return;
await RefreshAllAsync(forceRefresh: false);
StateHasChanged();
}), error => System.Diagnostics.Trace.TraceWarning("Health refresh failed: {0}", error));
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
@@ -996,8 +989,11 @@
_ => "bg-secondary"
};
private volatile bool _disposed;
public void Dispose()
{
_disposed = true;
_refreshTimer?.Dispose();
}
}
@@ -96,12 +96,47 @@
private HashSet<string>? _filterRevealed;
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
// Compare immutable value snapshots, rather than entity/list reference identity:
// callers may rename, move, or edit composition children in place.
private sealed record TreeInput(string Kind, int Id, int? ParentId, string Name, bool Derived = false, bool Inherited = false, string? KeyOverride = null);
private List<TreeInput>? _treeInputs;
private Dictionary<int, IReadOnlyList<TemplateTreeNode>> _extraChildren = new();
protected override void OnParametersSet()
{
BuildTree();
var inputs = new List<TreeInput>();
foreach (var folder in Folders)
inputs.Add(new("folder", folder.Id, folder.ParentFolderId, folder.Name));
_extraChildren = new();
foreach (var template in Templates)
{
inputs.Add(new("template", template.Id, template.FolderId, template.Name, template.IsDerived));
if (!template.IsDerived && ExtraTemplateChildren is not null)
{
var children = ExtraTemplateChildren(template);
_extraChildren[template.Id] = children;
CaptureChildren(children, inputs);
}
}
if (_treeInputs is null || !inputs.SequenceEqual(_treeInputs))
{
_treeInputs = inputs;
BuildTree();
}
// Filter and RevealKeys are independent and may also be changed in place.
ApplyFilter();
}
private static void CaptureChildren(IReadOnlyList<TemplateTreeNode> children, List<TreeInput> inputs)
{
foreach (var child in children)
{
inputs.Add(new(child.Kind.ToString(), child.Id, null, child.Name, Inherited: child.IsInherited, KeyOverride: child.KeyOverride));
CaptureChildren(child.Children, inputs);
inputs.Add(new("end", 0, null, ""));
}
}
private void BuildTree()
{
var folderNodes = Folders.ToDictionary(
@@ -136,7 +171,7 @@
if (ExtraTemplateChildren != null)
{
foreach (var extra in ExtraTemplateChildren(t))
foreach (var extra in _extraChildren[t.Id])
{
tNode.Children.Add(extra);
}
@@ -231,6 +266,8 @@
Kind = node.Kind,
Id = node.Id,
Name = node.Name,
IsInherited = node.IsInherited,
KeyOverride = node.KeyOverride,
};
foreach (var k in keptChildren) clone.Children.Add(k);
if (keptChildren.Count > 0)