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
@@ -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>