Resolve all 46 low-severity architecture review findings
This commit is contained in:
+5
-5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user