dashboard: lazy-load BrowsePage via DashboardBrowseService

This commit is contained in:
Joseph Doherty
2026-05-28 13:10:10 -04:00
parent ba157b4b4f
commit 310dfab8b4
7 changed files with 444 additions and 12 deletions
@@ -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))
{
<div class="alert alert-info browse-stale-banner" role="status"
@onclick="ClearStaleBanner">@_staleBanner</div>
}
<div class="browse-tree">
@foreach (DashboardBrowseNode root in _roots)
{
<BrowseTreeNodeView Node="root"
OnAddTag="AddTagAsync"
OnTagContextMenu="OnTagContextMenu" />
OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="LoadChildrenAsync" />
}
</div>
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
@@ -186,7 +194,11 @@
@code {
private const int SearchResultLimit = 300;
private IReadOnlyList<DashboardBrowseNode> _roots = [];
private List<DashboardBrowseNode> _roots = [];
private ulong _cacheSequence;
private string? _staleBanner;
private CancellationTokenSource _deployCts = new();
private Task? _deployTask;
private string _search = string.Empty;
private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
private readonly List<string> _subscribed = [];
@@ -210,8 +222,58 @@
/// <inheritdoc />
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);
}
}
@@ -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.
*@
<div class="tree-node">
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
@if (Node.HasChildren)
@if (ShowToggle())
{
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
<button type="button" class="tree-toggle" @onclick="ToggleAsync" aria-label="Toggle">
@(_expanded ? "▾" : "▸")
</button>
}
@@ -18,7 +24,7 @@
{
<span class="tree-toggle tree-toggle-empty"></span>
}
<span class="tree-label" @onclick="Toggle">
<span class="tree-label" @onclick="ToggleAsync">
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
<span class="tree-name">@Node.DisplayName</span>
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
@@ -31,9 +37,27 @@
@if (_expanded)
{
<div class="tree-children">
@if (Node.LoadState == BrowseLoadState.Loading)
{
<div class="tree-load-status text-secondary">
<span class="tree-toggle tree-toggle-empty"></span>
<span>⌛ Loading…</span>
</div>
}
else if (Node.LoadState == BrowseLoadState.Error)
{
<div class="tree-load-status text-danger">
<span class="tree-toggle tree-toggle-empty"></span>
<span>Failed to load: @Node.LoadError</span>
</div>
}
@foreach (DashboardBrowseNode child in Node.Children)
{
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
<BrowseTreeNodeView Node="child"
OnAddTag="OnAddTag"
OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="OnLoadChildren" />
}
@foreach (GalaxyAttribute attr in Node.Attributes)
{
@@ -75,13 +99,52 @@
[Parameter]
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
/// <summary>
/// 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
/// <see cref="DashboardBrowseNode.Children"/> on the node it receives and then
/// trigger a re-render.
/// </summary>
[Parameter]
public Func<DashboardBrowseNode, Task>? 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;
}
}
}
@@ -29,6 +29,33 @@ public sealed class DashboardBrowseNode
/// <summary>True when the node has child objects or attributes to expand.</summary>
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
/// <summary>Whether this node has at least one matching descendant, per the
/// server's <c>child_has_children</c> projector hint. Controls whether the UI
/// shows an expand triangle before children have actually loaded.</summary>
public bool HasChildrenHint { get; init; }
/// <summary>The lazy-load state for this node's children.</summary>
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
/// <summary>Short error string if the last load attempt failed; null otherwise.</summary>
public string? LoadError { get; set; }
}
/// <summary>Lazy-load lifecycle of a browse node's children.</summary>
public enum BrowseLoadState
{
/// <summary>Children have not been requested yet.</summary>
NotLoaded,
/// <summary>A load is in progress.</summary>
Loading,
/// <summary>Children have been loaded into <see cref="DashboardBrowseNode.Children"/>.</summary>
Loaded,
/// <summary>The last load attempt failed; see <see cref="DashboardBrowseNode.LoadError"/>.</summary>
Error,
}
/// <summary>
@@ -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;
/// <summary>
/// Default <see cref="IDashboardBrowseService"/>. Delegates to
/// <see cref="GalaxyBrowseProjector"/> via the shared
/// <see cref="IGalaxyHierarchyCache"/>; no SQL hop, no gRPC self-call. Translates
/// the projector's <see cref="RpcException"/> on unknown parent into a friendly
/// <see cref="BrowseLevelResult.Error"/> so the Blazor circuit does not see an
/// unhandled exception.
/// </summary>
public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService
{
/// <inheritdoc />
public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence;
/// <inheritdoc />
public BrowseLevelResult GetRoots(BrowseFilterArgs filter)
=> ProjectLevel(parentId: null, filter);
/// <inheritdoc />
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<DashboardBrowseNode>(),
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<DashboardBrowseNode> 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<DashboardBrowseNode>(),
0,
(ulong)entry.Sequence,
Error: ex.Status.Detail);
}
}
}
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
services.AddSingleton<HubTokenService>();
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
services.AddHostedService<Hubs.AlarmsHubPublisher>();
@@ -0,0 +1,44 @@
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// In-process facade over <see cref="GalaxyBrowseProjector"/> for the dashboard's
/// BrowsePage. Provides one-level-at-a-time browse without going through the
/// gRPC stack. Backed by the same shared <see cref="IGalaxyHierarchyCache"/> the
/// gRPC service uses, so dashboard and external clients render identical results.
/// </summary>
public interface IDashboardBrowseService
{
/// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
ulong CurrentCacheSequence { get; }
}
/// <summary>Filter arguments forwarded into the projector.</summary>
/// <param name="TagNameGlob">Optional tag-name glob filter (case-insensitive).</param>
/// <param name="AlarmBearingOnly">When true, only return objects with at least one alarm-bearing attribute.</param>
/// <param name="HistorizedOnly">When true, only return objects with at least one historized attribute.</param>
public sealed record BrowseFilterArgs(
string? TagNameGlob = null,
bool AlarmBearingOnly = false,
bool HistorizedOnly = false);
/// <summary>One level of browse data plus the cache sequence it was projected from.</summary>
/// <param name="Nodes">The direct-child nodes for the requested parent (or roots when no parent given).</param>
/// <param name="TotalCount">Total matching sibling count, post-filter.</param>
/// <param name="CacheSequence">The cache entry sequence this result was projected from.</param>
/// <param name="Error">Friendly error string if the projection failed; null on success.</param>
public sealed record BrowseLevelResult(
IReadOnlyList<DashboardBrowseNode> Nodes,
int TotalCount,
ulong CacheSequence,
string? Error = null);
@@ -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;
/// <summary>
/// Coverage for <see cref="DashboardBrowseService"/> — 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 <c>child_has_children</c> hint, expose the
/// current cache sequence, and translate the projector's
/// <see cref="Grpc.Core.RpcException"/> on unknown parent into a friendly error
/// rather than propagating it into the Blazor circuit.
/// </summary>
public sealed class DashboardBrowseServiceTests
{
/// <summary>Verifies that <see cref="DashboardBrowseService.GetRoots"/> returns root-level
/// objects with the <c>HasChildrenHint</c> projector bit set, and reports the cache
/// sequence of the entry it projected from.</summary>
[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);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns the
/// direct children of the requested parent and that leaf nodes report
/// <c>HasChildrenHint == false</c>.</summary>
[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);
}
/// <summary>Verifies that an unknown parent id does not surface the projector's
/// <see cref="Grpc.Core.RpcException"/> — the service catches the NotFound and
/// returns an empty <see cref="BrowseLevelResult"/> with the error string set.</summary>
[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));
}
/// <summary>Verifies that swapping the cache's <c>Current</c> entry (as the refresh loop
/// does after a deploy bump) causes subsequent queries to observe the new sequence.</summary>
[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<GalaxyObject> 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<GalaxyObject> 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
{
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}