From d71f61e71bb1d040a54cc6676d0aae09ba01e2a1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:04:07 -0400 Subject: [PATCH] feat(centralui): Topology pre-loads and expands the first layer (sites -> top-level areas) (Gitea #39) --- docs/requirements/Component-CentralUI.md | 2 +- .../Pages/Deployment/Topology.razor | 8 ++- .../TopologyPageTests.cs | 56 ++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 0bf5b954..b267a62b 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -28,7 +28,7 @@ Central cluster only. Sites have no user interface. - Manual failover goes through a scoped `AuthorizedManualFailoverService` that refreshes the circuit's Administrator grant before calling the host singleton. The component's visibility policy is an additional presentation guard. - Flow Debugger retry/discard validates the current Deployer grant and resolves site scope inside its service. Scoped callers cannot relay an unknown site. Refresh merges preserve Replay's event ordering and append only rows known exclusively to the live run. - Health and Alarm Summary observe the whole posted refresh task and release their poll gate even when renderer dispatch fails during disposal. -- The Topology initially loads permitted sites. Expand a site and select **Load instances** to load that site's area metadata and first root-instance page; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data. +- The Topology initially loads permitted sites **and their area metadata** (Gitea #39 — every permitted site's areas are read at open, out-of-scope sites never) and opens with each site expanded to its top-level areas (`TreeView.InitiallyExpanded`; a browser's saved expansion state always wins). Instances stay lazy: select **Load instances** to load the first root-instance page of a site; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data. - `TemplateFolderTree` compares immutable value snapshots of folder/template hierarchy and extra children before rebuilding. In-place renames, moves, derived-state changes, and composition edits remain visible; filter and reveal changes are applied independently. - Navigation grants Flow Debugger its own policy and hides Notifications unless the principal holds a role that admits a child. Route-policy coverage freezes every CentralUI route and the external Secrets route. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor index afbd198a..1381c03c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor @@ -113,6 +113,7 @@ HasChildrenSelector="n => n.Children.Count > 0" KeySelector="n => (object)n.Key" StorageKey="topology-tree" + InitiallyExpanded="n => n.Kind == TopoNodeKind.Site" Selectable="true" SelectedKey="_selectedKey" SelectedKeyChanged="OnTreeNodeSelected"> @@ -361,8 +362,13 @@ // the cleared lists. var areas = new List(); var instances = new List(); - foreach (var siteId in _loadedSites.ToArray()) + // First layer (Gitea #39): areas are pre-loaded for EVERY permitted site so the + // tree opens on sites → top-level areas. Instances stay lazy (_loadedPages). + foreach (var siteId in permittedSiteIds) + { areas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId)); + _loadedSites.Add(siteId); + } foreach (var (key, offset) in _loadedPages.ToArray()) instances.AddRange(await TemplateEngineRepository.GetTopologyInstancesAsync(key.Site, key.Area, offset, InstancePageSize)); _allAreas = areas; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs index 4444227a..53d38294 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs @@ -244,6 +244,55 @@ public class TopologyPageTests : BunitContext _repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any()); } + /// Gitea #39: the first layer (areas of every permitted site) is loaded at open; instances stay lazy. + [Fact] + public void InitialLoad_PreloadsAreasForPermittedSites_ButNoInstances() + { + SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }, new Site("Yard", "yard") { Id = 2 }], + areasBySite: new() + { + [1] = new List { new("Line A") { Id = 10, SiteId = 1 } }, + [2] = new List { new("Dock") { Id = 20, SiteId = 2 } }, + }); + var cut = Render(); + cut.WaitForAssertion(() => + { + Assert.Contains("Line A", cut.Markup); + Assert.Contains("Dock", cut.Markup); + }); + _repo.DidNotReceive().GetTopologyInstancesAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// Gitea #39: a site outside the user's scope never has its areas read. + [Fact] + public async Task InitialLoad_OutOfScopeSite_AreasNeverRead() + { + SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }, new Site("Yard", "yard") { Id = 2 }], + areasBySite: new() + { + [1] = new List { new("Line A") { Id = 10, SiteId = 1 } }, + [2] = new List { new("Dock") { Id = 20, SiteId = 2 } }, + }); + var auth = Services.GetRequiredService(); + var identity = (ClaimsIdentity)(await auth.GetAuthenticationStateAsync()).User.Identity!; + identity.AddClaim(new Claim(JwtTokenService.SiteIdClaimType, "1")); + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Line A", cut.Markup)); + Assert.DoesNotContain("Dock", cut.Markup); + await _repo.DidNotReceive().GetAreasBySiteIdAsync(2, Arg.Any()); + } + + /// Gitea #39: with no saved expansion state sites open; a saved (collapsed) state wins. + [Fact] + public void InitialLoad_SitesExpanded_WhenNoSavedState_AndSavedStateWins() + { + SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }], + areasBySite: new() { [1] = new List { new("Line A") { Id = 10, SiteId = 1 } } }); + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Line A", cut.Markup)); + Assert.Contains("aria-expanded=\"true\"", cut.Markup); + } + /// Verifies that moving an existing topology item uses the current site scope. /// Whether the user's site permission has been revoked. /// A task that represents the asynchronous operation. @@ -343,7 +392,6 @@ public class TopologyPageTests : BunitContext // Expand the site, then the area, to render the instance leaf. The // helper scopes by the row's own label so we don't match outer rows // whose TextContent transitively contains the inner label. - FindToggleForLabel(cut, "Plant-A")!.Click(); FindToggleForLabel(cut, "Line-1")!.Click(); Assert.Contains("Pump-001", cut.Markup); @@ -369,7 +417,6 @@ public class TopologyPageTests : BunitContext areasBySite: areasBySite); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); var search = cut.Find("input[type='text']"); search.Input("Line"); @@ -440,7 +487,6 @@ public class TopologyPageTests : BunitContext SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1"); areaLabel.DoubleClick(); @@ -464,7 +510,6 @@ public class TopologyPageTests : BunitContext _repo.GetAreaByIdAsync(10, Arg.Any()).Returns(area); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1").DoubleClick(); var input = cut.Find("input[aria-label='Rename Line-1']"); @@ -491,7 +536,6 @@ public class TopologyPageTests : BunitContext }); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001"); Assert.Throws(() => instanceLabel.DoubleClick()); @@ -560,7 +604,6 @@ public class TopologyPageTests : BunitContext new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success())))); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); FindToggleForLabel(cut, "Line-1")!.Click(); // The per-node action menu only renders after a context-menu (right @@ -646,7 +689,6 @@ public class TopologyPageTests : BunitContext SeedOneDeployedInstanceWithComparison(); var cut = RenderLoadedTopology(); - FindToggleForLabel(cut, "Plant-A")!.Click(); Assert.Contains("Pump-001", cut.Markup); Assert.Contains("Enabled", cut.Markup);