b3ebf583ad
12-task bite-sized plan executing the approved design. Includes native task persistence file.
1079 lines
47 KiB
Markdown
1079 lines
47 KiB
Markdown
# Lazy Browse (BrowseChildren) Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Add an OPC UA-style `BrowseChildren` gRPC RPC and make the dashboard's BrowsePage load one level at a time, both backed by a new parent→children index on the existing shared `IGalaxyHierarchyCache`.
|
|
|
|
**Architecture:** A new `GalaxyBrowseProjector` is added next to the existing `GalaxyHierarchyProjector`. It reuses the cache's immutable `GalaxyHierarchyCacheEntry` and a new `ChildrenByParent` map built once per refresh inside `GalaxyHierarchyIndex`. Two consumers call the projector: a new `BrowseChildren` handler on `GalaxyRepositoryGrpcService`, and a new in-process `IDashboardBrowseService` used by `BrowsePage.razor` for click-to-expand rendering. The full Galaxy SQL refresh path is unchanged.
|
|
|
|
**Tech Stack:** .NET 10 / ASP.NET Core gRPC / Blazor Server (Bootstrap CSS only) / xUnit. Wire contract: proto3 (`additive only` per the file's wire-compatibility policy).
|
|
|
|
**Design source:** `docs/plans/2026-05-28-lazy-browse-design.md`.
|
|
|
|
### Deliberate deviation from the design doc
|
|
|
|
The design's Section 2 said stale page tokens return `FailedPrecondition`. The existing `DiscoverHierarchy` returns `InvalidArgument` for the same condition (`GalaxyRepositoryGrpcService.cs:258-263`). Section 1 of the design said "same as today's DiscoverHierarchy." We follow today's behavior — `InvalidArgument` — so the two RPCs stay consistent. Update the design doc's error table accordingly in Task 10.
|
|
|
|
---
|
|
|
|
## Task 0: Worktree & branch
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~1 min
|
|
**Parallelizable with:** none
|
|
|
|
**Files:**
|
|
- Create: (none)
|
|
|
|
**Step 1: Verify clean working tree from current `main`**
|
|
|
|
Run: `git status --short`
|
|
Expected: only the untracked `*-docs-*.md` files listed in the repo gitStatus snapshot are allowed; no modified tracked files.
|
|
|
|
**Step 2: Branch**
|
|
|
|
Run: `git checkout -b feat/lazy-browse-children`
|
|
Expected: `Switched to a new branch 'feat/lazy-browse-children'`
|
|
|
|
No commit.
|
|
|
|
---
|
|
|
|
## Task 1: Add proto contract for `BrowseChildren`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** none (everything else needs the generated types)
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto:21-33` (add RPC), end of file (add messages)
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` (extend)
|
|
|
|
**Step 1: Write the failing contract round-trip test**
|
|
|
|
Append a new fact to `ProtobufContractRoundTripTests` (match the file's existing pattern — read it first to see how it builds messages and asserts serialize/parse identity). The test should:
|
|
- Build a `BrowseChildrenRequest` with every scalar/oneof field set (one option of the `parent` oneof per assertion, all three exercised in separate Theory rows or three Facts).
|
|
- Build a `BrowseChildrenReply` with two `GalaxyObject`s, two `child_has_children` booleans, `next_page_token`, `total_child_count`, and `cache_sequence` set.
|
|
- Round-trip both via `parser.ParseFrom(message.ToByteArray())` and assert `Equals`.
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
|
|
Expected: compile failure (types not yet generated).
|
|
|
|
**Step 3: Edit the proto**
|
|
|
|
In `src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto`:
|
|
|
|
Inside `service GalaxyRepository`, after `WatchDeployEvents`, add:
|
|
|
|
```proto
|
|
// Returns the direct children of a parent object (or the root objects when
|
|
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
|
// one level at a time instead of paging the full hierarchy. Filters mirror
|
|
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
|
rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);
|
|
```
|
|
|
|
At the end of the file (after `GalaxyAttribute`), append:
|
|
|
|
```proto
|
|
message BrowseChildrenRequest {
|
|
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
|
oneof parent {
|
|
int32 parent_gobject_id = 1;
|
|
string parent_tag_name = 2;
|
|
string parent_contained_path = 3;
|
|
}
|
|
|
|
// Maximum number of direct children to return. Server default 500; cap 5000.
|
|
int32 page_size = 4;
|
|
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
|
// cache sequence, parent selector, and the filter set; a mismatch returns
|
|
// InvalidArgument.
|
|
string page_token = 5;
|
|
|
|
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
|
repeated int32 category_ids = 6;
|
|
repeated string template_chain_contains = 7;
|
|
string tag_name_glob = 8;
|
|
optional bool include_attributes = 9;
|
|
bool alarm_bearing_only = 10;
|
|
bool historized_only = 11;
|
|
}
|
|
|
|
message BrowseChildrenReply {
|
|
// Direct children matching the filter, sorted areas-first then by
|
|
// case-insensitive display name (same order as the dashboard tree).
|
|
repeated GalaxyObject children = 1;
|
|
// Non-empty when another page of siblings is available.
|
|
string next_page_token = 2;
|
|
// Total matching direct children of the parent (post-filter).
|
|
int32 total_child_count = 3;
|
|
// Parallel array, indexed with `children`. True when the child has at least
|
|
// one matching descendant under the same filter set. Lets a UI choose
|
|
// whether to draw an expand triangle without an extra round trip.
|
|
repeated bool child_has_children = 4;
|
|
// Cache sequence this reply was projected from. Clients may pass it back as
|
|
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
|
uint64 cache_sequence = 5;
|
|
}
|
|
```
|
|
|
|
**Step 4: Regenerate and verify**
|
|
|
|
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
|
Expected: succeeds; `src/ZB.MOM.WW.MxGateway.Contracts/Generated/GalaxyRepository.cs` now contains `BrowseChildrenRequest`, `BrowseChildrenReply`, and `GalaxyRepositoryBase.BrowseChildren`.
|
|
|
|
**Step 5: Re-run the test**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
|
|
Expected: PASS.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/galaxy_repository.proto \
|
|
src/ZB.MOM.WW.MxGateway.Contracts/Generated/ \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
|
|
git commit -m "contracts: add BrowseChildren RPC for lazy hierarchy browse"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Extend `GalaxyHierarchyIndex` with `ChildrenByParent`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none (Tasks 3, 4 read this)
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs` (add property, populate in `Build`)
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs` with four facts:
|
|
|
|
1. `ChildrenByParent_RootsUnderSentinelZero` — three objects, all `ParentGobjectId == 0`, → `index.ChildrenByParent[0]` has all three.
|
|
2. `ChildrenByParent_NestedHierarchy_LinksParentToChildren` — area `A` (id 1, parent 0), object `B` (id 2, parent 1), object `C` (id 3, parent 2) → `[0]={A}`, `[1]={B}`, `[2]={C}`.
|
|
3. `ChildrenByParent_SelfParentedObject_DoesNotRecurse` — object with `GobjectId == ParentGobjectId == 5` is included under sentinel `0` (parent ignored when equal to self), never under itself.
|
|
4. `ChildrenByParent_SortsAreasFirstThenByDisplayName` — under one parent: instance `"zebra"`, area `"alpha"`, area `"beta"` → returned order `[alpha, beta, zebra]`, areas-first, OrdinalIgnoreCase within group.
|
|
|
|
Build `GalaxyObject` lists directly with `new GalaxyObject { GobjectId = ..., ParentGobjectId = ..., IsArea = ..., ContainedName = ..., BrowseName = ..., TagName = ... }`. Call `GalaxyHierarchyIndex.Build(objects)`.
|
|
|
|
**Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests`
|
|
Expected: compile failure (no `ChildrenByParent` property yet).
|
|
|
|
**Step 3: Add `ChildrenByParent` and populate it**
|
|
|
|
In `GalaxyHierarchyIndex.cs`:
|
|
|
|
- Add ctor parameter and read-only property:
|
|
|
|
```csharp
|
|
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
|
```
|
|
|
|
Include it in the private ctor and in `Empty` (use `new Dictionary<int, IReadOnlyList<GalaxyObjectView>>()`).
|
|
|
|
- In `Build`, after the existing per-object loop builds `views`/`viewsById`/`tagsByAddress`, add a single pass:
|
|
|
|
```csharp
|
|
Dictionary<int, List<GalaxyObjectView>> childrenByParent = new();
|
|
foreach (GalaxyObjectView view in views)
|
|
{
|
|
int parentKey = view.Object.ParentGobjectId;
|
|
// Treat self-parented (corrupt) rows as roots; matches DashboardBrowseTreeBuilder.
|
|
if (parentKey == view.Object.GobjectId)
|
|
{
|
|
parentKey = 0;
|
|
}
|
|
if (!childrenByParent.TryGetValue(parentKey, out List<GalaxyObjectView>? bucket))
|
|
{
|
|
bucket = [];
|
|
childrenByParent[parentKey] = bucket;
|
|
}
|
|
bucket.Add(view);
|
|
}
|
|
```
|
|
|
|
- Sort each bucket: areas first, then `OrdinalIgnoreCase` on display name. The display name is `view.Object.BrowseName ?? view.Object.ContainedName ?? view.Object.TagName` (mirror `DashboardBrowseNode.DisplayName`). Add a private helper `DisplayNameOf(view)` so the projector and tests can reuse it later.
|
|
|
|
```csharp
|
|
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
|
|
{
|
|
bucket.Sort(CompareByAreaThenDisplayName);
|
|
}
|
|
|
|
Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
|
|
foreach (KeyValuePair<int, List<GalaxyObjectView>> kvp in childrenByParent)
|
|
{
|
|
readOnlyChildren[kvp.Key] = kvp.Value;
|
|
}
|
|
```
|
|
|
|
- Pass `readOnlyChildren` into the ctor.
|
|
|
|
**Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyHierarchyIndexTests`
|
|
Expected: 4/4 PASS.
|
|
|
|
**Step 5: Run the full server test suite to catch regressions**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
Expected: all green.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs
|
|
git commit -m "galaxy: add ChildrenByParent index for level-at-a-time browse"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Add `GalaxyBrowseProjector.ProjectChildren`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 5 (dashboard service — relies only on this projector via abstraction)
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs`
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs`
|
|
|
|
**Step 1: Write failing tests**
|
|
|
|
In `GalaxyBrowseProjectorTests.cs`, build a fixture with five objects under one root area:
|
|
|
|
```
|
|
Area "Plant" (id 1, parent 0)
|
|
├── Instance "Mixer_001" (id 2, parent 1)
|
|
├── Instance "Mixer_002" (id 3, parent 1)
|
|
├── Area "Line_A" (id 4, parent 1)
|
|
│ └── Instance "Sensor_A1" (id 5, parent 4)
|
|
└── Instance "Pump_001" (id 6, parent 1)
|
|
```
|
|
|
|
Tests (one fact each):
|
|
|
|
1. `Project_NoParent_ReturnsRootArea` — empty `parent` oneof → one child: `Plant`. `ChildHasChildren[0] == true`.
|
|
2. `Project_ByParentGobjectId_ReturnsDirectChildren` — `ParentGobjectId = 1` → 4 children in order `[Line_A, Mixer_001, Mixer_002, Pump_001]` (area first, then alpha). `ChildHasChildren = [true, false, false, false]`.
|
|
3. `Project_ByParentTagName_ResolvesParent` — `ParentTagName = "Plant"` → same 4 children.
|
|
4. `Project_ByParentContainedPath_ResolvesParent` — `ParentContainedPath = "Plant"` → same 4 children.
|
|
5. `Project_UnknownParent_ThrowsNotFound` — `ParentGobjectId = 999` → `RpcException` with `StatusCode.NotFound`.
|
|
6. `Project_PagedAcrossSiblings_ReturnsEverySiblingOnce` — page size 2 over 4 children at parent 1, walk both pages, assert union is the full set, no duplicates.
|
|
7. `Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren` — `TagNameGlob = "Mixer*"`, parent 1 → only `Mixer_001`/`Mixer_002`; `ChildHasChildren = [false, false]` (matching glob, no matching descendants).
|
|
8. `Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter` — mark only `Sensor_A1` as historized; with `HistorizedOnly = true` and parent 1, only `Line_A` returned, `ChildHasChildren = [true]` (because its descendant matches).
|
|
9. `Project_IncludeAttributesFalse_ReturnsSkeletons` — verify `children[i].Attributes` is empty after the call.
|
|
10. `Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs` — pass `["Plant/Line_*"]` as `browseSubtreeGlobs`, parent 1 → only `Line_A`.
|
|
|
|
Use the same `CreateEntry` helper pattern as the existing `GalaxyHierarchyProjectorTests`. Read that file first if uncertain.
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests`
|
|
Expected: compile failure (projector doesn't exist).
|
|
|
|
**Step 3: Implement `GalaxyBrowseChildrenResult`**
|
|
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
|
|
public sealed record GalaxyBrowseChildrenResult(
|
|
IReadOnlyList<GalaxyObject> Children,
|
|
IReadOnlyList<bool> ChildHasChildren,
|
|
int TotalChildCount,
|
|
string FilterSignature);
|
|
```
|
|
|
|
**Step 4: Implement `GalaxyBrowseProjector.ProjectChildren`**
|
|
|
|
`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`:
|
|
|
|
```csharp
|
|
using System.Collections.Concurrent;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Grpc.Core;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
|
|
public static class GalaxyBrowseProjector
|
|
{
|
|
private static readonly ConditionalWeakTable<
|
|
GalaxyHierarchyCacheEntry,
|
|
ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();
|
|
|
|
public static GalaxyBrowseChildrenResult ProjectChildren(
|
|
GalaxyHierarchyCacheEntry entry,
|
|
BrowseChildrenRequest request,
|
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
|
int offset,
|
|
int pageSize)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entry);
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
|
|
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
|
|
|
int parentId = ResolveParentId(entry, request);
|
|
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
|
|
FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);
|
|
|
|
bool includeAttributes = IncludeAttributes(request);
|
|
int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
|
|
List<GalaxyObject> page = new(Math.Max(0, end - offset));
|
|
List<bool> hasChildren = new(Math.Max(0, end - offset));
|
|
for (int i = offset; i < end; i++)
|
|
{
|
|
page.Add(CloneObject(filtered.Children[i].Object, includeAttributes));
|
|
hasChildren.Add(filtered.HasMatchingDescendant[i]);
|
|
}
|
|
|
|
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
|
}
|
|
|
|
private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
|
{
|
|
switch (request.ParentCase)
|
|
{
|
|
case BrowseChildrenRequest.ParentOneofCase.None:
|
|
return 0;
|
|
case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
|
|
if (request.ParentGobjectId == 0) return 0;
|
|
if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
|
|
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
|
return request.ParentGobjectId;
|
|
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
|
{
|
|
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
|
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
|
|
if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
|
return match.Object.GobjectId;
|
|
}
|
|
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
|
{
|
|
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
|
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
|
|
if (match is null) throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
|
return match.Object.GobjectId;
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private static FilteredChildren GetFilteredChildren(
|
|
GalaxyHierarchyCacheEntry entry,
|
|
BrowseChildrenRequest request,
|
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
|
int parentId,
|
|
string filterSignature)
|
|
{
|
|
ConcurrentDictionary<string, FilteredChildren> memo =
|
|
FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));
|
|
|
|
return memo.GetOrAdd(
|
|
filterSignature,
|
|
static (_, state) =>
|
|
{
|
|
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
|
|
IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
|
|
? list
|
|
: Array.Empty<GalaxyObjectView>();
|
|
|
|
List<GalaxyObjectView> matched = [];
|
|
List<bool> hasMatching = [];
|
|
foreach (GalaxyObjectView view in directChildren)
|
|
{
|
|
if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)) continue;
|
|
if (!MatchesFilters(view.Object, state.Request)) continue;
|
|
matched.Add(view);
|
|
hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
|
|
}
|
|
|
|
return new FilteredChildren(matched, hasMatching);
|
|
},
|
|
(Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
|
|
}
|
|
|
|
private static bool HasMatchingDescendant(
|
|
GalaxyObjectView parent,
|
|
GalaxyHierarchyIndex index,
|
|
BrowseChildrenRequest request,
|
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
|
{
|
|
if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? children)) return false;
|
|
|
|
Stack<GalaxyObjectView> stack = new();
|
|
foreach (GalaxyObjectView child in children) stack.Push(child);
|
|
while (stack.Count > 0)
|
|
{
|
|
GalaxyObjectView candidate = stack.Pop();
|
|
if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
|
|
&& MatchesFilters(candidate.Object, request))
|
|
{
|
|
return true;
|
|
}
|
|
if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? grandchildren))
|
|
{
|
|
foreach (GalaxyObjectView grandchild in grandchildren) stack.Push(grandchild);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? globs)
|
|
=> globs is null || globs.Count == 0 || globs.Any(g => GalaxyGlobMatcher.IsMatch(view.ContainedPath, g));
|
|
|
|
private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
|
|
{
|
|
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) return false;
|
|
foreach (string tpl in request.TemplateChainContains)
|
|
{
|
|
if (!obj.TemplateChain.Any(t => t.Contains(tpl, StringComparison.OrdinalIgnoreCase))) return false;
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
|
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) return false;
|
|
if (request.AlarmBearingOnly && !obj.Attributes.Any(a => a.IsAlarm)) return false;
|
|
if (request.HistorizedOnly && !obj.Attributes.Any(a => a.IsHistorized)) return false;
|
|
return true;
|
|
}
|
|
|
|
private static bool IncludeAttributes(BrowseChildrenRequest request)
|
|
=> !request.HasIncludeAttributes || request.IncludeAttributes;
|
|
|
|
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
|
{
|
|
GalaxyObject clone = source.Clone();
|
|
if (!includeAttributes) clone.Attributes.Clear();
|
|
return clone;
|
|
}
|
|
|
|
public static string ComputeFilterSignature(
|
|
BrowseChildrenRequest request,
|
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
|
int parentId)
|
|
{
|
|
StringBuilder sb = new();
|
|
sb.Append("parent=").Append(parentId);
|
|
sb.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
|
sb.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
|
sb.Append("|glob=").Append(request.TagNameGlob);
|
|
sb.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
|
sb.Append("|alarm=").Append(request.AlarmBearingOnly);
|
|
sb.Append("|hist=").Append(request.HistorizedOnly);
|
|
sb.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
|
return Convert.ToHexString(hash, 0, 12);
|
|
}
|
|
|
|
private sealed record FilteredChildren(
|
|
IReadOnlyList<GalaxyObjectView> Children,
|
|
IReadOnlyList<bool> HasMatchingDescendant);
|
|
}
|
|
```
|
|
|
|
**Step 5: Run the projector tests**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GalaxyBrowseProjectorTests`
|
|
Expected: 10/10 PASS.
|
|
|
|
**Step 6: Run the full server suite**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
Expected: all green.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs \
|
|
src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs
|
|
git commit -m "galaxy: add GalaxyBrowseProjector for direct-children projection"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Wire `BrowseChildren` into `GalaxyRepositoryGrpcService`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 5
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:23-26`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs`
|
|
|
|
**Step 1: Failing scope resolver test**
|
|
|
|
In `GatewayGrpcScopeResolverTests.cs` (read it first to match style — likely a Theory or per-request-type Fact), add a case that `Resolve(new BrowseChildrenRequest())` returns `GatewayScopes.MetadataRead`.
|
|
|
|
**Step 2: Failing service test**
|
|
|
|
In `GalaxyRepositoryGrpcServiceTests.cs` (read it first to match the existing fixtures — likely a `FakeGalaxyHierarchyCache` helper), add facts:
|
|
|
|
1. `BrowseChildren_RootCall_ReturnsRoots` — cache contains the area-tree from Task 3; empty `parent` → reply contains exactly the root areas, `CacheSequence > 0`, `ChildHasChildren` populated.
|
|
2. `BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable` — fresh cache with `Status == Unknown` and no completion → `RpcException(Unavailable)` after the 5s budget. (Use a fake cache whose `WaitForFirstLoadAsync` never completes within the test's short token.)
|
|
3. `BrowseChildren_StaleToken_ReturnsInvalidArgument` — projector returns `next_page_token` with sequence S; bump cache to sequence S+1; second call with that token → `InvalidArgument` with "stale" in the message.
|
|
4. `BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument` — page 1 with one filter set, page 2 same token but different `TagNameGlob` → `InvalidArgument`.
|
|
5. `BrowseChildren_BrowseSubtreesConstraint_FiltersChildren` — fake identity accessor returns one browse-subtree glob; result is intersected.
|
|
|
|
**Step 3: Run the tests to verify they fail**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GalaxyRepositoryGrpcServiceTests|FullyQualifiedName~GatewayGrpcScopeResolverTests"`
|
|
Expected: compile failure (handler doesn't exist yet) or runtime failures for the resolver case.
|
|
|
|
**Step 4: Add `BrowseChildren` to the scope resolver**
|
|
|
|
In `GatewayGrpcScopeResolver.cs` lines 23-26, extend the metadata-read clause:
|
|
|
|
```csharp
|
|
TestConnectionRequest or
|
|
GetLastDeployTimeRequest or
|
|
DiscoverHierarchyRequest or
|
|
WatchDeployEventsRequest or
|
|
BrowseChildrenRequest => GatewayScopes.MetadataRead,
|
|
```
|
|
|
|
**Step 5: Implement the handler**
|
|
|
|
In `GalaxyRepositoryGrpcService.cs`, after `DiscoverHierarchy` and before `WatchDeployEvents`, add:
|
|
|
|
```csharp
|
|
private const int DefaultBrowsePageSize = 500;
|
|
// (MaxDiscoverPageSize is reused as MaxBrowsePageSize — same 5000 cap.)
|
|
|
|
public override async Task<BrowseChildrenReply> BrowseChildren(
|
|
BrowseChildrenRequest request,
|
|
ServerCallContext context)
|
|
{
|
|
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
|
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
|
|
|
|
if (!entry.HasData)
|
|
{
|
|
throw new RpcException(new Status(
|
|
StatusCode.Unavailable,
|
|
ResolveUnavailableMessage(entry)));
|
|
}
|
|
|
|
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
|
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
|
|
|
// Resolve the parent id once so the page-token signature can include it.
|
|
int parentId = ResolveParentIdForToken(entry, request);
|
|
string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(request, browseSubtrees, parentId);
|
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
|
|
|
GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren(
|
|
entry,
|
|
request,
|
|
browseSubtrees,
|
|
pageToken.Offset,
|
|
pageSize);
|
|
|
|
if (pageToken.Offset > result.TotalChildCount)
|
|
{
|
|
throw new RpcException(new Status(
|
|
StatusCode.InvalidArgument,
|
|
"BrowseChildren page_token is outside the current children set."));
|
|
}
|
|
|
|
BrowseChildrenReply reply = new()
|
|
{
|
|
TotalChildCount = result.TotalChildCount,
|
|
CacheSequence = (ulong)entry.Sequence,
|
|
};
|
|
reply.Children.Add(result.Children);
|
|
reply.ChildHasChildren.Add(result.ChildHasChildren);
|
|
|
|
int nextOffset = pageToken.Offset + result.Children.Count;
|
|
if (nextOffset < result.TotalChildCount)
|
|
{
|
|
reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
|
|
}
|
|
|
|
return reply;
|
|
}
|
|
|
|
private static int ResolveBrowsePageSize(int requested)
|
|
{
|
|
if (requested < 0)
|
|
{
|
|
throw new RpcException(new Status(
|
|
StatusCode.InvalidArgument,
|
|
"BrowseChildren page_size must be greater than zero when provided."));
|
|
}
|
|
int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
|
|
return Math.Min(pageSize, MaxDiscoverPageSize);
|
|
}
|
|
|
|
// Lightweight parent resolver used only for signature computation. Re-throws
|
|
// NotFound consistently with the projector so the error surface matches.
|
|
private static int ResolveParentIdForToken(
|
|
GalaxyDb.GalaxyHierarchyCacheEntry entry,
|
|
BrowseChildrenRequest request)
|
|
{
|
|
return request.ParentCase switch
|
|
{
|
|
BrowseChildrenRequest.ParentOneofCase.None => 0,
|
|
BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
|
|
request.ParentGobjectId == 0 ? 0
|
|
: entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
|
|
? request.ParentGobjectId
|
|
: throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
|
BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
|
|
entry.Index.ObjectViews.FirstOrDefault(
|
|
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
|
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
|
BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
|
|
entry.Index.ObjectViews.FirstOrDefault(
|
|
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
|
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
|
_ => 0,
|
|
};
|
|
}
|
|
```
|
|
|
|
Reuse the existing `FormatPageToken` / `ParsePageToken` / `PageToken` record. They use `sequence:filterSig:offset` text encoding — that's fine for browse too. The `ParsePageToken` error messages currently say "DiscoverHierarchy"; leave them — the contract is the same and rewording them costs a wider edit without value. (If a code reviewer flags this in Task 11, generalize the message strings to "page_token is invalid/stale" then.)
|
|
|
|
**Step 6: Run the tests**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
Expected: all green, including the new BrowseChildren tests.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs \
|
|
src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs
|
|
git commit -m "grpc: implement BrowseChildren handler + metadata:read scope"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Dashboard browse service & lazy `BrowsePage`
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 4 (different files, both depend on Tasks 2 & 3)
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseService.cs`
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardBrowseService.cs`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseModel.cs` (add lazy node fields, keep existing builder)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor`
|
|
- Modify: the file that registers dashboard services in DI (find with `grep -rn "AddScoped.*Dashboard\|AddSingleton.*Dashboard" src/ZB.MOM.WW.MxGateway.Server` — register `IDashboardBrowseService` next to the others)
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs` (new file)
|
|
|
|
**Step 1: Failing tests for the service**
|
|
|
|
`DashboardBrowseServiceTests.cs`:
|
|
|
|
1. `GetRoots_ReturnsRootObjects_WithHasChildrenHint` — fixture with one root area containing one child; `GetRoots(default)` → one root node, `HasChildrenHint == true`.
|
|
2. `GetChildren_ByParentGobjectId_ReturnsDirectChildren` — verify it routes through the projector and returns the same nodes the projector does.
|
|
3. `GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag` — translate the projector's NotFound `RpcException` into a `BrowseLevelResult.NotFound` so the Blazor UI can render a friendly message instead of propagating a server exception to the circuit. (Design says project the gRPC error to the UI; this is the dashboard-friendly translation.)
|
|
4. `CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt` — bump the fake cache; subsequent calls report the new sequence.
|
|
|
|
**Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~DashboardBrowseServiceTests`
|
|
Expected: compile failure.
|
|
|
|
**Step 3: Implement the contracts and service**
|
|
|
|
`IDashboardBrowseService.cs`:
|
|
|
|
```csharp
|
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
public interface IDashboardBrowseService
|
|
{
|
|
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
|
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
|
ulong CurrentCacheSequence { get; }
|
|
}
|
|
|
|
public sealed record BrowseFilterArgs(
|
|
string? TagNameGlob = null,
|
|
bool AlarmBearingOnly = false,
|
|
bool HistorizedOnly = false);
|
|
|
|
public sealed record BrowseLevelResult(
|
|
IReadOnlyList<DashboardBrowseNode> Nodes,
|
|
int TotalCount,
|
|
ulong CacheSequence,
|
|
string? Error = null);
|
|
```
|
|
|
|
`DashboardBrowseModel.cs` — extend `DashboardBrowseNode` with three new optional fields (additive, doesn't break the existing eager-tree consumer):
|
|
|
|
```csharp
|
|
public bool HasChildrenHint { get; init; }
|
|
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
|
|
public string? LoadError { get; set; }
|
|
```
|
|
|
|
```csharp
|
|
public enum BrowseLoadState { NotLoaded, Loading, Loaded, Error }
|
|
```
|
|
|
|
Existing `HasChildren` already returns `Children.Count > 0 || Attributes.Count > 0` — leave it; the new `HasChildrenHint` is what the lazy UI consults before children have loaded.
|
|
|
|
`DashboardBrowseService.cs`:
|
|
|
|
```csharp
|
|
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;
|
|
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Register in DI alongside the other dashboard services (find the file, add `services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();`).
|
|
|
|
**Step 4: Rework `BrowseTreeNodeView.razor` for lazy expand**
|
|
|
|
Change the toggle path:
|
|
|
|
- Show the toggle when `Node.HasChildrenHint || Node.Attributes.Count > 0` (replaces the `HasChildren` check).
|
|
- On `Toggle`, if `LoadState == NotLoaded`, set `Loading`, invoke an async load callback (new `[Parameter] public Func<DashboardBrowseNode, Task>? OnLoadChildren`), then set `Loaded` or `Error`.
|
|
- While loading, show a small spinner glyph in place of the toggle arrow.
|
|
- Render children only when `LoadState == Loaded` (and the existing `_expanded` flag is true).
|
|
- Children render with `OnLoadChildren` passed through so nested nodes can lazy-load too.
|
|
|
|
**Step 5: Rework `BrowsePage.razor`**
|
|
|
|
- Inject `IDashboardBrowseService BrowseService` in place of (or alongside) the existing eager build.
|
|
- Replace the eager `BuildRoots()` call with `BrowseService.GetRoots(new BrowseFilterArgs(...))` on first render.
|
|
- Add an async method `LoadChildrenAsync(DashboardBrowseNode node)` that calls `BrowseService.GetChildren(node.Object.GobjectId, currentFilter)`, populates `node.Children`, marks `LoadState = Loaded`, and calls `StateHasChanged()`.
|
|
- Pass that as `OnLoadChildren` to the root `BrowseTreeNodeView`s.
|
|
- Subscribe to deploy events (the page already injects `IDashboardLiveDataService` — if it doesn't already expose a deploy hook, fall back to `IGalaxyDeployNotifier.SubscribeAsync` via a new injected dependency). On a sequence change, clear `_roots`, re-`GetRoots`, set a one-line banner `"Galaxy redeployed — tree refreshed."` (no toast, no modal). Banner clears on next user click.
|
|
- **Search path unchanged.** When `Search` is non-empty, keep the existing flat-list search code (it walks the full cache via `GalaxyHierarchyCache.Current.Objects`). Lazy is only for the unfiltered tree.
|
|
|
|
**Step 6: Run all server tests**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
Expected: all green.
|
|
|
|
**Step 7: Manual smoke on Windows host**
|
|
|
|
Document only — no automation here. The plan's executor flags this for the human:
|
|
|
|
```
|
|
ssh windev "Restart-Service MxAccessGw"
|
|
# Then visit http://10.100.0.48:5130/browse and verify:
|
|
# - Initial render shows only root areas
|
|
# - Clicking a triangle loads that level
|
|
# - A re-deploy banner appears after a forced redeploy
|
|
```
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/ \
|
|
src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseServiceTests.cs
|
|
git commit -m "dashboard: lazy-load BrowsePage via DashboardBrowseService"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Cross-language client smoke (skeleton walk)
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Tasks 7, 8, 9, 10 (different files)
|
|
|
|
**Files:**
|
|
- Modify: `clients/dotnet/<test project>/` — add one xUnit fact that opens a `GalaxyRepositoryClient`, calls `BrowseChildren` against the dev gateway only when `MXGATEWAY_API_KEY` is set, asserts `cache_sequence > 0`. Use `Skip = "..."` when env var missing.
|
|
|
|
Only the .NET client gets a new test in this task. The other language clients pick up the new RPC automatically via the next proto-regen in their build scripts; per-language test parity is a separate follow-up.
|
|
|
|
**Step 1: Add the test**
|
|
|
|
Read the existing `clients/dotnet` test project layout; mirror the pattern of an existing live-only `GalaxyRepository` test if one exists. Otherwise create `clients/dotnet/<TestProject>/Galaxy/BrowseChildrenSmokeTests.cs`.
|
|
|
|
```csharp
|
|
[SkippableFact]
|
|
public async Task BrowseChildren_LiveGateway_ReturnsRoots()
|
|
{
|
|
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
|
Skip.If(string.IsNullOrEmpty(apiKey), "Set MXGATEWAY_API_KEY to run.");
|
|
|
|
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
|
|
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
|
|
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
|
|
|
|
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
|
|
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
|
|
|
|
Assert.True(reply.CacheSequence > 0);
|
|
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
|
}
|
|
```
|
|
|
|
**Step 2: Build & run**
|
|
|
|
Run: `dotnet build clients/dotnet/MxGateway.Client.sln`
|
|
Expected: succeeds (proto regen picks up `BrowseChildren`).
|
|
|
|
Run: `dotnet test clients/dotnet/MxGateway.Client.sln --filter FullyQualifiedName~BrowseChildrenSmokeTests`
|
|
Expected: SKIPPED (no API key). With key set + gateway running, it passes.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add clients/dotnet/
|
|
git commit -m "client/dotnet: live smoke for BrowseChildren"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Regenerate Python/Go/Rust/Java client protos
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Tasks 6, 8, 9, 10
|
|
|
|
**Files:**
|
|
- Modify: `clients/python/src/mxgateway/generated/` (regen)
|
|
- Modify: `clients/go/...` (regen — check the existing build/gen script)
|
|
- Modify: `clients/rust/<crate>/generated*/` (regen)
|
|
- Modify: `clients/java/<module>/build/generated/` if checked in
|
|
|
|
**Step 1: Run each client's existing generation step**
|
|
|
|
Read each `clients/<lang>/README.md` for its codegen command. Run them in order:
|
|
|
|
```bash
|
|
cd clients/python && python -m pip install -e ".[dev]" && python -m build # or whatever regenerates protos
|
|
cd ../rust && cargo build --workspace
|
|
cd ../go && go generate ./...
|
|
cd ../java && gradle generateProto
|
|
```
|
|
|
|
Expected: each succeeds; `BrowseChildren` types appear in each client's generated tree.
|
|
|
|
**Step 2: Sanity build each**
|
|
|
|
```bash
|
|
(cd clients/python && python -m pytest --collect-only) # imports compile
|
|
(cd clients/rust && cargo check --workspace)
|
|
(cd clients/go && go build ./...)
|
|
(cd clients/java && gradle build -x test)
|
|
```
|
|
|
|
**Step 3: Commit (one per language to keep the diff reviewable)**
|
|
|
|
```bash
|
|
git add clients/python/
|
|
git commit -m "client/python: regenerate protos for BrowseChildren"
|
|
# repeat for each language
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Update `docs/GalaxyRepository.md`
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Tasks 6, 7, 9, 10
|
|
|
|
**Files:**
|
|
- Modify: `docs/GalaxyRepository.md`
|
|
|
|
**Step 1: Edit**
|
|
|
|
- In the "RPC Surface" table (around line 33-39), add one row:
|
|
|
|
```
|
|
| `BrowseChildren` | Returns the direct children of one parent object (or root objects when unset). Filters mirror `DiscoverHierarchy`. **Served from cache.** |
|
|
```
|
|
|
|
- Add a new subsection after the existing `DiscoverHierarchy` description, titled `### BrowseChildren`, covering:
|
|
- The `parent` oneof and how an empty oneof means "roots".
|
|
- Filter parity with `DiscoverHierarchy` (point readers to the existing filter docs rather than duplicating).
|
|
- `child_has_children` semantics — computed against the filtered descendant set.
|
|
- Page token contract: `(cache_sequence, parent_id, filter_signature, offset)`. Stale or filter-changed token → `InvalidArgument`. Unknown parent → `NotFound`.
|
|
- Default page size 500; cap 5000.
|
|
|
|
- Update the architecture diagram (around line 271-286) to show `GalaxyBrowseProjector` reused by both `GalaxyRepositoryGrpcService.BrowseChildren` and the new `DashboardBrowseService`.
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add docs/GalaxyRepository.md
|
|
git commit -m "docs: document BrowseChildren RPC and lazy browse architecture"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update `gateway.md` and per-client READMEs
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Tasks 6, 7, 8, 10
|
|
|
|
**Files:**
|
|
- Modify: `gateway.md` (one-line entry in the Galaxy RPC table)
|
|
- Modify: `clients/dotnet/README.md`
|
|
- Modify: `clients/python/README.md`
|
|
- Modify: `clients/rust/README.md`
|
|
- Modify: `clients/go/README.md` (or whatever the existing Go client README is — find with `find clients/go -iname 'README*'`)
|
|
- Modify: `clients/java/README.md`
|
|
|
|
**Step 1: Add to `gateway.md`**
|
|
|
|
Find the Galaxy RPC table (grep for `DiscoverHierarchy` in `gateway.md`); add a row identical in style to its neighbours describing `BrowseChildren`.
|
|
|
|
**Step 2: Per-client `Browsing lazily` snippet**
|
|
|
|
In each client README, add a short section under the existing Galaxy / browse docs:
|
|
|
|
```
|
|
### Browsing lazily
|
|
|
|
For OPC UA-style tree UIs, use BrowseChildren to walk one level at a time.
|
|
Pass an empty request for roots; subsequent calls supply parent_gobject_id
|
|
(or parent_tag_name / parent_contained_path). Each child's matching
|
|
`child_has_children[i]` tells you whether to draw an expand triangle.
|
|
Filter fields match DiscoverHierarchy.
|
|
```
|
|
|
|
Adapt the code snippet to each language's idiom (match the existing examples in that README).
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add gateway.md clients/*/README.md
|
|
git commit -m "docs: note BrowseChildren in gateway overview and client READMEs"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update `docs/DesignDecisions.md` and design doc
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~2 min
|
|
**Parallelizable with:** Tasks 6, 7, 8, 9
|
|
|
|
**Files:**
|
|
- Modify: `docs/DesignDecisions.md`
|
|
- Modify: `docs/plans/2026-05-28-lazy-browse-design.md`
|
|
|
|
**Step 1: Add a `DesignDecisions.md` entry**
|
|
|
|
Append a new bullet noting:
|
|
|
|
> **Lazy browse stays wire-only.** The gateway continues to pull the full Galaxy hierarchy on each deploy. The `BrowseChildren` RPC and lazy dashboard render only avoid sending and DOM-materializing the full tree — they do **not** push laziness into SQL or cache loading. Reasons: snapshot persistence and the dashboard summary both depend on a fully-materialized cache; lazy SQL would dramatically increase per-click latency on a deployment-heavy box and complicate the cold-start path.
|
|
|
|
**Step 2: Reconcile the design doc**
|
|
|
|
In `docs/plans/2026-05-28-lazy-browse-design.md`, change the error-mapping table row for "Stale `page_token`" from `FailedPrecondition` to `InvalidArgument` and add a one-line note that this matches `DiscoverHierarchy`'s pre-existing behavior.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add docs/DesignDecisions.md docs/plans/2026-05-28-lazy-browse-design.md
|
|
git commit -m "docs: record lazy-browse stays wire-only; align error mapping"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Final integration build and verification
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** none (final gate)
|
|
|
|
**Step 1: Build the whole solution**
|
|
|
|
Run: `dotnet build src/MxGateway.sln`
|
|
Expected: 0 warnings, 0 errors (the repo treats warnings as errors).
|
|
|
|
**Step 2: Run all gateway unit tests**
|
|
|
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
Expected: all green; new tests included.
|
|
|
|
**Step 3: Build the .NET client solution**
|
|
|
|
Run: `dotnet build clients/dotnet/MxGateway.Client.sln`
|
|
Expected: succeeds.
|
|
|
|
**Step 4: Self-review the diff**
|
|
|
|
Run: `git log --oneline main..HEAD` and `git diff --stat main..HEAD`. Sanity-check that each commit corresponds to one plan task; no stray edits.
|
|
|
|
**Step 5: Open PR (only after the user approves)**
|
|
|
|
The executor must stop here and ask the user before pushing or opening a PR.
|