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