Files
lmxopcua/docs/plans/2026-06-18-hosts-rows-abcip-nested-hygiene.md
T

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 (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)

/// <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: nulltemplateInstanceId: 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 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

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 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

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 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 ClusterNodesHostsNodeInfo(ClusterId, NodeId, Host, OpcUaPort); read DriverInstancesHostsDriverInstanceInfo(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)

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-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:

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):

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.