20 KiB
Hosts per-driver rows + AbCip nested-struct + Galaxy hygiene — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Goal: Ship three disjoint backlog items — a /hosts cluster-grouped Driver Instances section (#8),
AbCip nested-struct member expansion (#6), and Galaxy stale-comment hygiene + reconcile (#3/#13/#10).
Architecture: Reuse the existing driver-health DPS pipeline (add only an AdminUI-internal store
GetAll() + a pure view-model builder) so no Commons/interface/EF change is needed. AbCip stops discarding
the nested template id the member block already carries. Galaxy comments rewritten to shipped reality.
Tech Stack: .NET 10, Blazor Server (AdminUI), EF Core (ConfigDB read), xUnit + Shouldly. No bUnit.
Base: branch feat/hosts-rows-abcip-nested-hygiene off master f59680fa; design committed fec08915.
Execution note: implementers dispatched serially on this branch (avoids the shared-tree git-race lesson); per-task classification-driven review. T3 depends on T2.
Task 1: AbCip nested-struct template-id threading
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serial dispatch)
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs:57(AbCipUdtMemberrecord) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs:85-96 - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:~1179(the nested recursion call) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs(or a newAbCipNestedTemplateTests.csin the same project)
Context: Top-level controller-discovered UDT members are addressable (4e141402/4a7b0fde), but a
nested struct member's sub-shape can't be fetched because the prior fix passed templateInstanceId: null
when recursing. The member-info u16 actually carries the nested template id (bit 15 = struct flag, lower
12 bits = template instance id — see the decoder's own remarks block, lines 26-27). Stop discarding it.
Step 1: Extend AbCipUdtMember (optional trailing param — preserves existing 4-arg call sites)
/// <summary>One member of a Logix UDT.</summary>
/// <param name="Name">Member name.</param>
/// <param name="Offset">Byte offset from the struct start.</param>
/// <param name="DataType">Member CIP data type (Structure for a nested UDT).</param>
/// <param name="ArrayLength">Element count (1 for a scalar member).</param>
/// <param name="NestedTemplateId">For a <see cref="AbCipDataType.Structure"/> member, the nested UDT's
/// template instance id decoded from the member-info low 12 bits; <c>null</c> for a scalar member.</param>
public sealed record AbCipUdtMember(
string Name,
int Offset,
AbCipDataType DataType,
int ArrayLength,
uint? NestedTemplateId = null);
Step 2: Decode the nested template id in CipTemplateObjectDecoder.Decode (struct members only)
In the member loop (after computing isStruct/typeCode, ~line 85), use the full 12-bit mask (not the
byte-cast typeCode) for the nested id, and thread it into the member:
var isStruct = (info & MemberInfoStructFlag) != 0;
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
var dataType = isStruct
? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as
// the Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched.
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
Name: memberName,
Offset: offset,
DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize,
NestedTemplateId: nestedTemplateId));
Step 3: Thread the id at the recursion site (AbCipDriver.cs:~1179)
Change templateInstanceId: null → templateInstanceId: member.NestedTemplateId:
var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false);
ResolveDiscoveredUdtShapeAsync still consults the name-keyed seeded shapes (the SeedDiscoveredUdtShapeForTest
seam) first, then falls back to FetchUdtShapeAsync(deviceHostAddress, id, ct) — which reads @udt/{id} and
decodes — when an id is now present. Deep nesting works recursively (each level decodes its own members'
NestedTemplateId), bounded by the existing MaxUdtDepth + visited cycle guard.
Step 4: Tests (offline, via FakeTemplateReader)
- Decoder test: build a minimal Template Object blob (header + one struct member with
info = 0x8000 | 0x123, plus a scalar member) using the same byte layout the existing tests use; assert the struct member decodesDataType == StructureANDNestedTemplateId == 0x123u, and the scalar member decodesNestedTemplateId == null. - Threading test: drive the discovery/fan-out path for a top-level UDT whose member is a nested struct;
configure the
FakeTemplateReaderto return the nested template's blob for the nested id; assert the nested struct's leaf members become addressable (emitted) — i.e. the nested id now drives a real fetch rather than yieldingnull. Reuse the existing discovery test harness pattern in this project; do NOT useSeedDiscoveredUdtShapeForTestfor the nested shape (that would bypass what we're testing).
Step 5: Run + commit
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~Udt" -v minimal
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/<test-file>.cs
git commit -m "feat(abcip): thread nested-struct template id so nested UDT members are addressable (#6)"
Task 2: /hosts driver-snapshot store enumeration + pure grouping view-model
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serial; T3 depends on this)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs(pure builder + view-model records) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs(confirm the AdminUI test project path; create the file)
Step 1: Add GetAll() to the store interface
/// <summary>Returns a point-in-time snapshot of every driver instance's last-known health.</summary>
IReadOnlyCollection<DriverHealthChanged> GetAll();
Step 2: Implement in InMemoryDriverStatusSnapshotStore
/// <inheritdoc />
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.ToArray();
Step 3: Pure grouping view-model HostsDriverView
Define plain input/output records (no EF types) so it unit-tests offline. Group by ClusterId, union of
cluster ids from nodes + snapshots; enrich each snapshot with Name/DriverType by joining instances on
DriverInstanceId; unknown driver (snapshot with no matching instance) → null Name/DriverType fallback.
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// <summary>A cluster node as needed for the Hosts driver view (subset of the ConfigDB ClusterNode row).</summary>
public sealed record HostsNodeInfo(string ClusterId, string NodeId, string Host, int OpcUaPort);
/// <summary>A driver instance as needed for enrichment (subset of the ConfigDB DriverInstance row).</summary>
public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string ClusterId, string Name, string DriverType);
/// <summary>One driver row in a cluster group: live health enriched with the configured name + type.</summary>
public sealed record HostsDriverRow(
string DriverInstanceId,
string? Name,
string? DriverType,
string State,
DateTime? LastSuccessfulReadUtc,
string? LastError,
int ErrorCount5Min,
DateTime PublishedUtc);
/// <summary>One cluster group: the cluster's nodes + the cluster's live driver rows.</summary>
public sealed record HostsClusterGroup(
string ClusterId,
IReadOnlyList<HostsNodeInfo> Nodes,
IReadOnlyList<HostsDriverRow> Drivers);
/// <summary>Pure builder for the Hosts page Driver-Instances section — DB-agnostic + unit-testable.</summary>
public static class HostsDriverView
{
/// <summary>Builds the cluster-grouped driver view from live snapshots + cached ConfigDB rows.</summary>
/// <param name="snapshots">Live driver-health snapshots from the snapshot store.</param>
/// <param name="nodes">ConfigDB cluster nodes (for the per-cluster node list).</param>
/// <param name="instances">ConfigDB driver instances (for Name/DriverType enrichment).</param>
/// <returns>Cluster groups ordered by ClusterId; drivers ordered by Name ?? DriverInstanceId.</returns>
public static IReadOnlyList<HostsClusterGroup> Build(
IEnumerable<DriverHealthChanged> snapshots,
IEnumerable<HostsNodeInfo> nodes,
IEnumerable<HostsDriverInstanceInfo> instances)
{
var snapList = snapshots?.ToList() ?? new();
var nodeList = nodes?.ToList() ?? new();
var instById = (instances ?? Enumerable.Empty<HostsDriverInstanceInfo>())
.GroupBy(i => i.DriverInstanceId)
.ToDictionary(g => g.Key, g => g.First());
var clusterIds = snapList.Select(s => s.ClusterId)
.Concat(nodeList.Select(n => n.ClusterId))
.Where(id => !string.IsNullOrEmpty(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase);
var groups = new List<HostsClusterGroup>();
foreach (var clusterId in clusterIds)
{
var clusterNodes = nodeList
.Where(n => string.Equals(n.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n.NodeId, StringComparer.OrdinalIgnoreCase)
.ToList();
var drivers = snapList
.Where(s => string.Equals(s.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
.Select(s =>
{
instById.TryGetValue(s.DriverInstanceId, out var inst);
return new HostsDriverRow(
s.DriverInstanceId, inst?.Name, inst?.DriverType, s.State,
s.LastSuccessfulReadUtc, s.LastError, s.ErrorCount5Min, s.PublishedUtc);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ToList();
groups.Add(new HostsClusterGroup(clusterId, clusterNodes, drivers));
}
return groups;
}
}
Step 4: Tests (xUnit + Shouldly) in HostsDriverViewTests.cs:
- two clusters with nodes + snapshots → two groups, correct nodes + drivers per cluster;
- enrichment: a snapshot whose
DriverInstanceIdmatches an instance → Name + DriverType filled; - unknown-driver fallback: a snapshot with no matching instance → row present with null Name/DriverType;
- a cluster with nodes but no snapshots → group present, empty Drivers;
- empty inputs → empty list;
- driver ordering by Name then DriverInstanceId.
Step 5: Run + commit
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~HostsDriverView" -v minimal
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
git commit -m "feat(adminui): driver-snapshot GetAll() + pure Hosts driver-view builder (#8)"
Task 3: /hosts "Driver Instances" Razor section
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Task 2)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
Context: Add a new section below the existing Members section. Keep the Akka member rows untouched.
Pattern after DriverStatusPanel for the live subscription (in-process store read, marshal via InvokeAsync —
dodges the Traefik self-hub trap) and after Fleet.razor for the ConfigDB read.
Step 1: Injects + usings (top of Hosts.razor)
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration
@using Microsoft.EntityFrameworkCore
@inject IDriverStatusSnapshotStore DriverStore
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
(Confirm the ConfigDbContext type name + namespace from Fleet.razor.)
Step 2: State + lifecycle (in @code)
- Fields:
IReadOnlyList<HostsClusterGroup>? _driverGroups;plus cachedList<HostsNodeInfo> _nodes,List<HostsDriverInstanceInfo> _instances. OnInitializedAsync:await LoadConfigAsync();thenRebuildDriverGroups();thenDriverStore.SnapshotChanged += OnSnapshotChanged;LoadConfigAsync():await using var db = await DbFactory.CreateDbContextAsync();readClusterNodes→HostsNodeInfo(ClusterId, NodeId, Host, OpcUaPort); readDriverInstances→HostsDriverInstanceInfo(DriverInstanceId, ClusterId, Name, DriverType). (Confirm DbSet + property names.)RebuildDriverGroups():_driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances);OnSnapshotChanged(DriverHealthChanged _):InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); });(cheap — reuses cached config; a brand-new driver shows by id until the 5 s timer/Refresh re-reads config).- Extend the existing
Refresh/timer +RefreshAsyncto alsoawait LoadConfigAsync(); RebuildDriverGroups();so configured names stay current. (The timer callback is sync — make a small async path or callLoadConfigAsync()fire-and-forget-with-InvokeAsync; keep it simple and correct.) Dispose(): addDriverStore.SnapshotChanged -= OnSnapshotChanged;alongside the existing_timer?.Dispose().
Step 3: Markup — new section below the Members </section>
A panel "Driver instances" with a small notice that rows are cluster-scoped (health is per driver
instance across the cluster, not per Akka member). For each HostsClusterGroup: a sub-head
Cluster <ClusterId> + node chips (<NodeId> (<Host>:<OpcUaPort>)) + a table of drivers:
columns Driver (Name ?? DriverInstanceId, with the id in .mono small if a name exists), Type, Status
(chip via the mapping below), Last read, Last error, Errors/5 min. Handle _driverGroups is null (Loading)
and empty (a notice "No driver instances reporting yet").
Step 4: Local chip mapping (mirror DriverStatusPanel.ChipClass)
private static string DriverChipClass(string? state) => state switch
{
"Healthy" => "chip-ok",
"Degraded" => "chip-warn",
"Connecting" => "chip-warn",
"Reconnecting" => "chip-warn",
"Faulted" => "chip-bad",
_ => "chip-idle",
};
Step 5: Build + commit (no bUnit — proven live in Task 6)
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI -v minimal
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
git commit -m "feat(adminui): /hosts cluster-grouped Driver Instances section (#8)"
Also update the page's stale top-of-file comment (lines 2-5: "there are no per-driver host rows yet") to reflect the shipped section.
Task 4: Galaxy stale-comment hygiene
Classification: trivial Estimated implement time: ~4 min Parallelizable with: none (serial)
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs(~lines 52, 92, 669) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs(~line 19) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/HostStatusAggregator.cs(~line 21)
Comment-only. Verify each claim against the current code BEFORE rewriting (e.g. confirm _ownedMxSession
is built in InitializeAsync, the probe-watcher membership refresh after DiscoverAsync is wired,
HostStatusAggregator.OnHostStatusChanged is re-raised by the driver). Rewrite to shipped reality:
- Drop "PR 4.W" / "PR 4.4 supplies the production implementation; until then…" / "legacy-host backend handles reads in production" — Galaxy is the standard Equipment-kind driver now; reads ARE supported in production; the legacy Galaxy.Host/Proxy/Shared were retired in PR 7.2.
GalaxyDriverFactoryExtensions: drop "PR 4.W will add a server-sideGalaxy:Backendswitch … parity testing (Phase 5)" — never landed and won't (onlyGalaxyMxGatewayexists; the legacyGalaxyproxy type is retired). State the shipped reality (the distinct type name is historical; no backend switch).- Convert any genuinely-still-future note to a real
TODO, else delete the forward-ref.
Do NOT change any code — comments/XML-doc only.
Build + commit:
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy -v minimal
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/HostStatusAggregator.cs
git commit -m "docs(galaxy): rewrite stale PR-4.W/legacy-host forward-ref comments to shipped reality (#13)"
Task 5: Reconcile stillpending + memory (never-staged for stillpending)
Classification: trivial Estimated implement time: ~3 min Parallelizable with: none
Files:
-
Modify:
stillpending.md(never staged — local working copy only) -
Modify:
/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-OtOpcUa/memory/MEMORY.md -
Modify:
/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-OtOpcUa/memory/project_stillpending_backlog.md -
stillpending.md: mark item #10 SHIPPED (thectx-receiver guard is inScriptAnalysisService.cs:224,70e1bde9); strike #8 (this phase) + #6 (this phase) SHIPPED with the commit refs once known; note the AbCip finding (id was already in the member block, not a new query) + the Hosts cluster-scoped framing + the deferred per-member-nesting follow-up. -
Memory: update
project_stillpending_backlog.md+ theMEMORY.mdone-liner. KeepMEMORY.mdentries short (the index is already near its size cap).
Commit (memory files only — stillpending.md stays unstaged):
git add docs/plans/2026-06-18-hosts-rows-abcip-nested-hygiene.md.tasks.json
git commit -m "docs(plans): mark hosts/abcip/hygiene tasks complete + reconcile"
(Memory files live outside the repo; they are written, not committed.)
Task 6: Build + tests + live /run + finish
Classification: small Estimated implement time: ~5 min (+ live-verify) Parallelizable with: none
Step 1: Full build + the three affected test projects
dotnet build ZB.MOM.WW.OtOpcUa.slnx -v minimal
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests -v minimal
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests -v minimal
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests -v minimal
Expected: build clean; all green (AbCip + AdminUI + Galaxy). Note any pre-existing unrelated failures.
Step 2: Component A live /run (docker-dev) — rebuild BOTH central-1 AND central-2 (the :9200
AdminUI round-robins across both; a half-deploy serves stale code). Deploy a Modbus driver, open
http://localhost:9200/hosts, confirm the Driver Instances section lists the deployed driver(s) grouped by
cluster with a live status chip; drive a Reconnect from a driver page and confirm the chip updates. Record
the result honestly (login is disabled on the local rig — drive it yourself; do not defer to user sign-in).
Step 3: Finish — superpowers-extended-cc:finishing-a-development-branch → merge to master + push.