+ @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;
+ }
+}