docs(plans): implementation plan + tasks — Hosts rows + AbCip nested + Galaxy hygiene

This commit is contained in:
Joseph Doherty
2026-06-18 11:29:58 -04:00
parent fec0891584
commit 56c136b0fd
2 changed files with 432 additions and 0 deletions
@@ -0,0 +1,415 @@
# 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
/// <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:
```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] : $"<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`:
```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/<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**
```csharp
/// <summary>Returns a point-in-time snapshot of every driver instance's last-known health.</summary>
IReadOnlyCollection<DriverHealthChanged> GetAll();
```
**Step 2: Implement in `InMemoryDriverStatusSnapshotStore`**
```csharp
/// <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.
```csharp
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 `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<OtOpcUaConfigDbContext> DbFactory
```
(Confirm the ConfigDbContext type name + namespace from `Fleet.razor`.)
**Step 2: State + lifecycle (in `@code`)**
- Fields: `IReadOnlyList<HostsClusterGroup>? _driverGroups;` plus cached
`List<HostsNodeInfo> _nodes`, `List<HostsDriverInstanceInfo> _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 `</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`)**
```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.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-06-18-hosts-rows-abcip-nested-hygiene.md",
"designPath": "docs/plans/2026-06-18-hosts-rows-abcip-nested-hygiene-design.md",
"branch": "feat/hosts-rows-abcip-nested-hygiene",
"baseSha": "f59680fa",
"designCommit": "fec08915",
"executionState": "IN_PROGRESS",
"tasks": [
{"id": 1, "subject": "Task 1: AbCip nested-struct template-id threading (decoder + record + driver + tests)", "classification": "standard", "status": "pending"},
{"id": 2, "subject": "Task 2: /hosts store GetAll() + pure HostsDriverView grouping builder + tests", "classification": "standard", "status": "pending"},
{"id": 3, "subject": "Task 3: /hosts cluster-grouped Driver Instances Razor section", "classification": "standard", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Galaxy stale-comment hygiene (PR-4.W / legacy-host forward-refs)", "classification": "trivial", "status": "pending"},
{"id": 5, "subject": "Task 5: Reconcile stillpending #10/#6/#8 + memory", "classification": "trivial", "status": "pending"},
{"id": 6, "subject": "Task 6: Build + AbCip/AdminUI/Galaxy tests + Component A live /run + finish (merge+push)", "classification": "small", "status": "pending"}
],
"lastUpdated": "2026-06-18"
}