diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor index fa16556..282e436 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -2,6 +2,8 @@ @implements IAsyncDisposable @inject IGalaxyHierarchyCache GalaxyCache @inject IDashboardLiveDataService LiveData +@inject IDashboardBrowseService BrowseService +@inject IGalaxyDeployNotifier DeployNotifier @using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy @using ZB.MOM.WW.MxGateway.Server.Galaxy @@ -71,12 +73,18 @@ } else { + @if (!string.IsNullOrEmpty(_staleBanner)) + { +
@_staleBanner
+ }
@foreach (DashboardBrowseNode root in _roots) { + OnTagContextMenu="OnTagContextMenu" + OnLoadChildren="LoadChildrenAsync" /> }
Double-click a tag, or right-click for the menu.
@@ -186,7 +194,11 @@ @code { private const int SearchResultLimit = 300; - private IReadOnlyList _roots = []; + private List _roots = []; + private ulong _cacheSequence; + private string? _staleBanner; + private CancellationTokenSource _deployCts = new(); + private Task? _deployTask; private string _search = string.Empty; private IReadOnlyList _searchMatches = []; private readonly List _subscribed = []; @@ -210,8 +222,58 @@ /// protected override void OnInitialized() { - _roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects); + BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs()); + _roots = [.. roots.Nodes]; + _cacheSequence = roots.CacheSequence; _pollTask = PollLoopAsync(); + _deployTask = SubscribeToDeployEventsAsync(); + } + + private async Task LoadChildrenAsync(DashboardBrowseNode node) + { + BrowseLevelResult result = BrowseService.GetChildren(node.Object.GobjectId, new BrowseFilterArgs()); + node.Children.Clear(); + foreach (DashboardBrowseNode child in result.Nodes) + { + node.Children.Add(child); + } + + // First expand interaction also dismisses the stale banner — the user + // is clearly engaging with the tree, no need to keep nagging. + _staleBanner = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SubscribeToDeployEventsAsync() + { + try + { + await foreach (GalaxyDeployEventInfo info in DeployNotifier + .SubscribeAsync(_deployCts.Token) + .ConfigureAwait(false)) + { + // First Latest replay echoes the sequence we already projected + // from — skip those to avoid a spurious "redeployed" banner. + if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence) + { + continue; + } + + BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs()); + _roots = [.. roots.Nodes]; + _cacheSequence = roots.CacheSequence; + _staleBanner = "Galaxy redeployed — tree refreshed."; + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + } + } + + private void ClearStaleBanner() + { + _staleBanner = null; } private string HeaderLine() @@ -405,6 +467,7 @@ public async ValueTask DisposeAsync() { await _cts.CancelAsync(); + await _deployCts.CancelAsync(); if (_pollTask is not null) { try @@ -415,8 +478,19 @@ { } } + if (_deployTask is not null) + { + try + { + await _deployTask; + } + catch (OperationCanceledException) + { + } + } _cts.Dispose(); + _deployCts.Dispose(); GC.SuppressFinalize(this); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor index 57b4b0b..cc511e7 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor @@ -2,15 +2,21 @@ @* Recursive Browse hierarchy node. Renders one Galaxy object, its child - objects (recursively), and its attributes as right-clickable tag rows. - Expansion state is local; children render only while expanded. + objects (recursively, lazy-loaded on first expand), and its attributes as + right-clickable tag rows. Expansion state is local; children render only + while expanded. + + The expand triangle is shown whenever the server's child_has_children + projector hint is set (HasChildrenHint), even before children have been + loaded — clicking it triggers OnLoadChildren so the parent page can fill + in Node.Children, then the view re-renders. *@
- @if (Node.HasChildren) + @if (ShowToggle()) { - } @@ -18,7 +24,7 @@ { } - + @(Node.IsArea ? "▣" : "◇") @Node.DisplayName @if (!string.IsNullOrWhiteSpace(Node.Object.TagName) @@ -31,9 +37,27 @@ @if (_expanded) {
+ @if (Node.LoadState == BrowseLoadState.Loading) + { +
+ + ⌛ Loading… +
+ } + else if (Node.LoadState == BrowseLoadState.Error) + { +
+ + Failed to load: @Node.LoadError +
+ } + @foreach (DashboardBrowseNode child in Node.Children) { - + } @foreach (GalaxyAttribute attr in Node.Attributes) { @@ -75,13 +99,52 @@ [Parameter] public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; } + /// + /// Invoked on first expand when the projector hint says this node has children + /// but they have not been fetched yet. The callback is expected to populate + /// on the node it receives and then + /// trigger a re-render. + /// + [Parameter] + public Func? OnLoadChildren { get; set; } + private bool _expanded; - private void Toggle() + // The triangle is shown whenever the projector says children exist (even + // pre-load), or attributes are already present, or already-loaded children + // are sitting on the node. + private bool ShowToggle() { - if (Node.HasChildren) + return Node.HasChildrenHint + || Node.Attributes.Count > 0 + || Node.Children.Count > 0; + } + + private async Task ToggleAsync() + { + if (!ShowToggle()) { - _expanded = !_expanded; + return; + } + + _expanded = !_expanded; + + if (_expanded + && Node.HasChildrenHint + && Node.LoadState == BrowseLoadState.NotLoaded + && OnLoadChildren is not null) + { + Node.LoadState = BrowseLoadState.Loading; + try + { + await OnLoadChildren(Node); + Node.LoadState = BrowseLoadState.Loaded; + } + catch (Exception ex) + { + Node.LoadState = BrowseLoadState.Error; + Node.LoadError = ex.Message; + } } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs index 8cf7d5d..93bab58 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs @@ -29,6 +29,33 @@ public sealed class DashboardBrowseNode /// True when the node has child objects or attributes to expand. public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0; + + /// Whether this node has at least one matching descendant, per the + /// server's child_has_children projector hint. Controls whether the UI + /// shows an expand triangle before children have actually loaded. + public bool HasChildrenHint { get; init; } + + /// The lazy-load state for this node's children. + public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded; + + /// Short error string if the last load attempt failed; null otherwise. + public string? LoadError { get; set; } +} + +/// Lazy-load lifecycle of a browse node's children. +public enum BrowseLoadState +{ + /// Children have not been requested yet. + NotLoaded, + + /// A load is in progress. + Loading, + + /// Children have been loaded into . + Loaded, + + /// The last load attempt failed; see . + Error, } /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs new file mode 100644 index 0000000..95d1953 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs @@ -0,0 +1,82 @@ +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Default . Delegates to +/// via the shared +/// ; no SQL hop, no gRPC self-call. Translates +/// the projector's on unknown parent into a friendly +/// so the Blazor circuit does not see an +/// unhandled exception. +/// +public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService +{ + /// + public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence; + + /// + public BrowseLevelResult GetRoots(BrowseFilterArgs filter) + => ProjectLevel(parentId: null, filter); + + /// + public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter) + => ProjectLevel(parentId: parentGobjectId, filter); + + private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter) + { + ArgumentNullException.ThrowIfNull(filter); + + GalaxyHierarchyCacheEntry entry = cache.Current; + if (!entry.HasData) + { + return new BrowseLevelResult( + Array.Empty(), + 0, + (ulong)entry.Sequence, + Error: "Galaxy hierarchy is not loaded yet."); + } + + BrowseChildrenRequest request = new() + { + TagNameGlob = filter.TagNameGlob ?? string.Empty, + AlarmBearingOnly = filter.AlarmBearingOnly, + HistorizedOnly = filter.HistorizedOnly, + }; + if (parentId is int pid) + { + request.ParentGobjectId = pid; + } + + try + { + GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( + entry, + request, + browseSubtreeGlobs: null, + offset: 0, + pageSize: int.MaxValue); + + List nodes = new(result.Children.Count); + for (int i = 0; i < result.Children.Count; i++) + { + nodes.Add(new DashboardBrowseNode + { + Object = result.Children[i], + HasChildrenHint = result.ChildHasChildren[i], + }); + } + return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) + { + return new BrowseLevelResult( + Array.Empty(), + 0, + (ulong)entry.Sequence, + Error: ex.Status.Detail); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index b8459f5..fc45d3f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs new file mode 100644 index 0000000..53becb9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs @@ -0,0 +1,44 @@ +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// In-process facade over for the dashboard's +/// BrowsePage. Provides one-level-at-a-time browse without going through the +/// gRPC stack. Backed by the same shared the +/// gRPC service uses, so dashboard and external clients render identical results. +/// +public interface IDashboardBrowseService +{ + /// Returns root browse nodes (objects with no parent). + /// Filter arguments forwarded to the projector. + BrowseLevelResult GetRoots(BrowseFilterArgs filter); + + /// Returns the direct children of the given parent gobject id. + /// The Galaxy gobject id of the parent to expand. + /// Filter arguments forwarded to the projector. + BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter); + + /// Current Galaxy cache sequence. Bumps after each successful refresh. + ulong CurrentCacheSequence { get; } +} + +/// Filter arguments forwarded into the projector. +/// Optional tag-name glob filter (case-insensitive). +/// When true, only return objects with at least one alarm-bearing attribute. +/// When true, only return objects with at least one historized attribute. +public sealed record BrowseFilterArgs( + string? TagNameGlob = null, + bool AlarmBearingOnly = false, + bool HistorizedOnly = false); + +/// One level of browse data plus the cache sequence it was projected from. +/// The direct-child nodes for the requested parent (or roots when no parent given). +/// Total matching sibling count, post-filter. +/// The cache entry sequence this result was projected from. +/// Friendly error string if the projection failed; null on success. +public sealed record BrowseLevelResult( + IReadOnlyList Nodes, + int TotalCount, + ulong CacheSequence, + string? Error = null); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs new file mode 100644 index 0000000..9df9b44 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs @@ -0,0 +1,141 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Coverage for — the in-process facade the +/// Blazor BrowsePage uses to walk the Galaxy hierarchy one level at a time. The +/// service must surface the projector's child_has_children hint, expose the +/// current cache sequence, and translate the projector's +/// on unknown parent into a friendly error +/// rather than propagating it into the Blazor circuit. +/// +public sealed class DashboardBrowseServiceTests +{ + /// Verifies that returns root-level + /// objects with the HasChildrenHint projector bit set, and reports the cache + /// sequence of the entry it projected from. + [Fact] + public void GetRoots_ReturnsRootObjects_WithHasChildrenHint() + { + StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11)); + DashboardBrowseService service = new(cache); + + BrowseLevelResult result = service.GetRoots(new BrowseFilterArgs()); + + Assert.Single(result.Nodes); + Assert.Equal("Plant", result.Nodes[0].Object.TagName); + Assert.True(result.Nodes[0].HasChildrenHint); + Assert.Equal(11UL, result.CacheSequence); + Assert.Null(result.Error); + } + + /// Verifies that returns the + /// direct children of the requested parent and that leaf nodes report + /// HasChildrenHint == false. + [Fact] + public void GetChildren_ByParentGobjectId_ReturnsDirectChildren() + { + StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11)); + DashboardBrowseService service = new(cache); + + BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs()); + + Assert.Single(result.Nodes); + Assert.Equal("Mixer_001", result.Nodes[0].Object.TagName); + Assert.False(result.Nodes[0].HasChildrenHint); + Assert.Null(result.Error); + } + + /// Verifies that an unknown parent id does not surface the projector's + /// — the service catches the NotFound and + /// returns an empty with the error string set. + [Fact] + public void GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag() + { + StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11)); + DashboardBrowseService service = new(cache); + + BrowseLevelResult result = service.GetChildren(parentGobjectId: 999, new BrowseFilterArgs()); + + Assert.Empty(result.Nodes); + Assert.NotNull(result.Error); + Assert.False(string.IsNullOrEmpty(result.Error)); + } + + /// Verifies that swapping the cache's Current entry (as the refresh loop + /// does after a deploy bump) causes subsequent queries to observe the new sequence. + [Fact] + public void CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt() + { + StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11)); + DashboardBrowseService service = new(cache); + + BrowseLevelResult before = service.GetRoots(new BrowseFilterArgs()); + Assert.Equal(11UL, before.CacheSequence); + + cache.Current = CreateEntry(CreateObjects(), sequence: 12); + + BrowseLevelResult after = service.GetRoots(new BrowseFilterArgs()); + Assert.Equal(12UL, after.CacheSequence); + } + + private static IReadOnlyList CreateObjects() + { + // Fixture: an Area "Plant" (id 1, parent 0, IsArea=true) containing one + // Instance "Mixer_001" (id 2, parent 1). Both with no attributes — the + // service is exercised through the projector, which only needs id / + // parent / IsArea / display name to build the level slice. + return + [ + new GalaxyObject + { + GobjectId = 1, + ParentGobjectId = 0, + TagName = "Plant", + BrowseName = "Plant", + IsArea = true, + }, + new GalaxyObject + { + GobjectId = 2, + ParentGobjectId = 1, + TagName = "Mixer_001", + BrowseName = "Mixer_001", + IsArea = false, + }, + ]; + } + + private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList objects, long sequence) + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = sequence, + LastSuccessAt = DateTimeOffset.UtcNow, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + DashboardSummary = DashboardGalaxySummary.Unknown with + { + Status = DashboardGalaxyStatus.Healthy, + ObjectCount = objects.Count, + }, + ObjectCount = objects.Count, + }; + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry initial) : IGalaxyHierarchyCache + { + /// Mutable so a single test can swap the entry mid-flight. + public GalaxyHierarchyCacheEntry Current { get; set; } = initial; + + /// + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +}