# 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` (`AbCipUdtMember` record) - 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 new `AbCipNestedTemplateTests.cs` in 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)** ```csharp /// One member of a Logix UDT. /// Member name. /// Byte offset from the struct start. /// Member CIP data type (Structure for a nested UDT). /// Element count (1 for a scalar member). /// For a member, the nested UDT's /// template instance id decoded from the member-info low 12 bits; null for a scalar member. 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: ```csharp 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] : $""; 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`: ```csharp 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 decodes `DataType == Structure` AND `NestedTemplateId == 0x123u`, and the scalar member decodes `NestedTemplateId == null`. - **Threading test:** drive the discovery/fan-out path for a top-level UDT whose member is a nested struct; configure the `FakeTemplateReader` to 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 yielding `null`. Reuse the existing discovery test harness pattern in this project; do NOT use `SeedDiscoveredUdtShapeForTest` for the nested shape (that would bypass what we're testing). **Step 5: Run + commit** ```bash 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/.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** ```csharp /// Returns a point-in-time snapshot of every driver instance's last-known health. IReadOnlyCollection GetAll(); ``` **Step 2: Implement in `InMemoryDriverStatusSnapshotStore`** ```csharp /// public IReadOnlyCollection 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. ```csharp namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; /// A cluster node as needed for the Hosts driver view (subset of the ConfigDB ClusterNode row). public sealed record HostsNodeInfo(string ClusterId, string NodeId, string Host, int OpcUaPort); /// A driver instance as needed for enrichment (subset of the ConfigDB DriverInstance row). public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string ClusterId, string Name, string DriverType); /// One driver row in a cluster group: live health enriched with the configured name + type. public sealed record HostsDriverRow( string DriverInstanceId, string? Name, string? DriverType, string State, DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc); /// One cluster group: the cluster's nodes + the cluster's live driver rows. public sealed record HostsClusterGroup( string ClusterId, IReadOnlyList Nodes, IReadOnlyList Drivers); /// Pure builder for the Hosts page Driver-Instances section — DB-agnostic + unit-testable. public static class HostsDriverView { /// Builds the cluster-grouped driver view from live snapshots + cached ConfigDB rows. /// Live driver-health snapshots from the snapshot store. /// ConfigDB cluster nodes (for the per-cluster node list). /// ConfigDB driver instances (for Name/DriverType enrichment). /// Cluster groups ordered by ClusterId; drivers ordered by Name ?? DriverInstanceId. public static IReadOnlyList Build( IEnumerable snapshots, IEnumerable nodes, IEnumerable instances) { var snapList = snapshots?.ToList() ?? new(); var nodeList = nodes?.ToList() ?? new(); var instById = (instances ?? Enumerable.Empty()) .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(); 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 `DriverInstanceId` matches 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** ```bash 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) ```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 DbFactory ``` (Confirm the ConfigDbContext type name + namespace from `Fleet.razor`.) **Step 2: State + lifecycle (in `@code`)** - Fields: `IReadOnlyList? _driverGroups;` plus cached `List _nodes`, `List _instances`. - `OnInitializedAsync`: `await LoadConfigAsync();` then `RebuildDriverGroups();` then `DriverStore.SnapshotChanged += OnSnapshotChanged;` - `LoadConfigAsync()`: `await using var db = await DbFactory.CreateDbContextAsync();` read `ClusterNodes` → `HostsNodeInfo(ClusterId, NodeId, Host, OpcUaPort)`; read `DriverInstances` → `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 + `RefreshAsync` to also `await LoadConfigAsync(); RebuildDriverGroups();` so configured names stay current. (The timer callback is sync — make a small async path or call `LoadConfigAsync()` fire-and-forget-with-InvokeAsync; keep it simple and correct.) - `Dispose()`: add `DriverStore.SnapshotChanged -= OnSnapshotChanged;` alongside the existing `_timer?.Dispose()`. **Step 3: Markup — new section below the Members ``** 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 ` + node chips (` (:)`) + 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`)** ```csharp 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) ```bash 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-side `Galaxy:Backend` switch … parity testing (Phase 5)" — never landed and won't (only `GalaxyMxGateway` exists; the legacy `Galaxy` proxy 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:** ```bash 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 (the `ctx`-receiver guard is in `ScriptAnalysisService.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` + the `MEMORY.md` one-liner. Keep `MEMORY.md` entries short (the index is already near its size cap). **Commit (memory files only — stillpending.md stays unstaged):** ```bash 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** ```bash 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.