Files
lmxopcua/docs/plans/2026-05-28-adminui-driver-pages-plan.md
T
Joseph Doherty c1c68c9134 docs(plans): AdminUI driver-specific pages implementation plan
48-task plan across 10 phases (Contracts split, shared sections,
router/picker, 9 typed pages, retire generic editor, live status,
Test Connect, Reconnect/Restart, address pickers, E2E). Tracked in
sibling .tasks.json with dependency graph.
2026-05-28 08:36:53 -04:00

49 KiB
Raw Blame History

AdminUI Driver-Specific Pages Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Replace DriverEdit.razor (generic JSON editor) with typed per-driver pages for all 9 driver types, each with Test Connect, live runtime status panel (Reconnect/Restart), and a driver-specific tag/address picker.

Architecture: 9 new Driver.<Type>.Contracts csprojs hold the Options POCOs (moved from runtime projects). AdminUI gains 9 thin ProjectReferences — no native deps leak. 9 typed *DriverPage.razor components share <DriverFormShell> + section/picker/status/test components in Components/Shared/Drivers/. Test Connect routes through AdminOperationsActor to per-driver IDriverProbe impls. Live status uses an Akka DistributedPubSub bridge → SignalR hub → Blazor panel (same pattern as the existing FleetStatusSignalRBridge).

Tech Stack: .NET 10 Blazor Server, EF Core (SQL Server), Akka.NET (cluster + DistributedPubSub), SignalR, OPC Foundation OPC UA .NET Standard stack, xUnit + Shouldly.

Authoritative design: docs/plans/2026-05-28-adminui-driver-pages-design.md. Re-read its sections when a task references them.


Phase 0 — Preconditions

Task 0.1: Create AdminUI test project (currently absent)

Classification: small Estimated implement time: ~4 min Parallelizable with: none (every later test task depends on this)

Files:

  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/_PlaceholderTests.cs
  • Modify: ZB.MOM.WW.OtOpcUa.slnx (add the new project)

Step 1: Create csproj targeting net10.0, <IsPackable>false</IsPackable>. Package refs: xunit, xunit.runner.visualstudio, Microsoft.NET.Test.Sdk, Shouldly. Project ref: ..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj. Copy structure from a peer like tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/*.csproj.

Step 2: Add a single _PlaceholderTests.cs with one passing fact so the project compiles + the test runner discovers something.

Step 3: Add <Solution><Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj"/></Solution> to ZB.MOM.WW.OtOpcUa.slnx (match the existing element style).

Step 4: Run dotnet build tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests then dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests. Both succeed.

Step 5: Commit. test(adminui): scaffold AdminUI.Tests project

Decision (deferred from design §8.4): no bUnit. All Razor render tests degrade to logic-only tests on @code { } blocks. Razor markup is covered by the integration tests in Phase 6/7/8.


Phase 1 — Contracts Projects (Driver Options → POCO-only siblings)

Pattern (apply to every task in this phase):

For driver type <Type> (folder src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>/, options file <Type>DriverOptions.cs):

  1. Create new csproj src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <!-- NO ProjectReference. NO PackageReference. Pure POCO. -->
    </Project>
    
  2. git mv the options file from the runtime project into the contracts project. Preserve namespace (ZB.MOM.WW.OtOpcUa.Driver.<Type> → keep the same namespace ZB.MOM.WW.OtOpcUa.Driver.<Type> declaration so consumers don't change). If the options file usings anything that isn't System.* or System.ComponentModel.DataAnnotations, strip that dep — most options classes are pure POCO already; if any pulls a runtime-only type, leave a // TODO: extract <type> too and capture it in a follow-up task here.
  3. Add <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts\ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts.csproj" /> to the runtime project's csproj <ItemGroup>.
  4. Add the new contracts csproj to ZB.MOM.WW.OtOpcUa.slnx.
  5. dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type> → clean. dotnet build ZB.MOM.WW.OtOpcUa.slnx → clean.
  6. Commit. refactor(driver-<type>): extract <Type>DriverOptions to .Contracts

Task 1.1: Modbus contracts

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1.2 1.9 (different folders, different csprojs)

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj
  • Move: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cssrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj (add ProjectReference)
  • Modify: ZB.MOM.WW.OtOpcUa.slnx

Follow the Phase 1 pattern above.

Task 1.2: AbCip contracts

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 1.1, 1.3 1.9

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj
  • Move: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs → contracts project
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj, ZB.MOM.WW.OtOpcUa.slnx

Task 1.3: AbLegacy contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/*, move AbLegacyDriverOptions.cs, update Driver.AbLegacy.csproj + slnx.

Task 1.4: S7 contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/*, move S7DriverOptions.cs, update Driver.S7.csproj + slnx.

Task 1.5: TwinCAT contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/*, move TwinCATDriverOptions.cs, update Driver.TwinCAT.csproj + slnx.

Task 1.6: FOCAS contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/*, move FocasDriverOptions.cs, update Driver.FOCAS.csproj + slnx.

Task 1.7: OpcUaClient contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/*, move OpcUaClientDriverOptions.cs, update Driver.OpcUaClient.csproj + slnx.

Task 1.8: Galaxy contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/*, move Config/GalaxyDriverOptions.cs (place at root of contracts project — drop the Config/ subdir), update Driver.Galaxy.csproj + slnx.

Task 1.9: Wonderware Historian client contracts

Classification: small · Estimated implement time: ~3 min · Parallelizable with: 1.11.9 (except itself) Files: Create src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/*, move WonderwareHistorianClientOptions.cs, update Driver.Historian.Wonderware.Client.csproj + slnx.

Task 1.10: Validate the full solution + add ProbeTimeout property to each Options class

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on 1.11.9)

Files:

  • Modify: each of the 9 *DriverOptions.cs files just moved into the contracts projects.

Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx — clean.

Step 2: Add a ProbeTimeout property to each Options class with a driver-appropriate default:

/// <summary>Timeout for the AdminUI Test Connect probe. Server-side max = 60s.</summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default {n}s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = <default>;

Defaults: Modbus 5, AbCip 5, AbLegacy 5, S7 5, TwinCAT 10, FOCAS 10, OpcUaClient 15, Galaxy 30, Wonderware Historian 15.

Step 3: dotnet build ZB.MOM.WW.OtOpcUa.slnx — clean (any driver runtime code that constructs the Options via positional record syntax may break — fix by using with { ProbeTimeoutSeconds = N } or making it a property with default).

Step 4: dotnet test ZB.MOM.WW.OtOpcUa.slnx — all existing tests still pass.

Step 5: Commit. feat(drivers): expose ProbeTimeoutSeconds on every driver Options class


Phase 2 — Shared section components

Task 2.1: DriverFormShell.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2.2, 2.3

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormShell.razor

Step 1: Implement panel chrome (<section class="panel rise"> + <div class="panel-head">) with Title, ChildContent, Footer render fragments. Cancel/Save/Delete buttons via parameters: OnSave EventCallback, OnCancel EventCallback, OnDelete EventCallback? (null hides delete). Busy bool drives spinner + disabled. Error banner from Error string param.

Step 2: Pattern-match the existing DriverEdit.razor save bar (lines 116128) — same visual layout.

Step 3: No code-behind logic; pure presentation.

Step 4: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI — clean.

Step 5: Commit. feat(adminui): add DriverFormShell shared component

Task 2.2: DriverIdentitySection.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2.1, 2.3

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor

Step 1: Component renders the identity fields lifted from DriverEdit.razor lines 3888: DriverInstanceId (read-only when not new), Name, NamespaceId (select from Namespace[] passed in), Enabled. Bind via IdentityModel record passed as @bind-Value.

Step 2: Define IdentityModel record in the same file's @code block: public sealed record IdentityModel { ... }. Properties match the existing FormModel Identity fields, with their [Required] / [RegularExpression] attributes preserved.

Step 3: Component takes IsNew bool, Namespaces list.

Step 4: Build clean.

Step 5: Commit. feat(adminui): add DriverIdentitySection shared component

Task 2.3: DriverResilienceSection.razor

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2.1, 2.2

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor

Step 1: For this PR, keep the existing JSON textarea for Polly overrides — typed-form-ifying Polly is out of scope (Section 10 of the design says so implicitly). The component wraps the textarea + help text from DriverEdit.razor lines 101109 in a panel.

Step 2: Bind [Parameter] public string? ResilienceConfig { get; set; } + EventCallback<string?> ResilienceConfigChanged.

Step 3: Build clean.

Step 4: Commit. feat(adminui): add DriverResilienceSection shared component

Task 2.4: Wire the three new sections into existing DriverEdit.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: none (depends on 2.12.3)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor

Step 1: Replace lines 3888 with <DriverIdentitySection @bind-Value="_identityModel" Namespaces="_namespaces" IsNew="IsNew" />. Wire _identityModel to/from _form in OnInitializedAsync and SubmitAsync.

Step 2: Replace lines 101109 with <DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />.

Step 3: Wrap the form in <DriverFormShell Busy="_busy" Error="_error" OnSave="SubmitAsync" OnCancel="@(...)" OnDelete="@(IsNew ? null : DeleteAsync)">.

Step 4: Smoke test: dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host (admin role), open /clusters/<existing>/drivers/<existing>, page renders identically to before. (Driver config JSON textarea + identity fields + save bar visually unchanged.)

Step 5: Commit. refactor(adminui): drive DriverEdit.razor through shared section components


Phase 3 — Router + Type Picker

Task 3.1: DriverTypePicker.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 3.2

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverTypePicker.razor

Step 1: @page "/clusters/{ClusterId}/drivers/new" (this replaces the same route on the existing DriverEdit.razor — order matters; we'll yank the old route in Task 3.3).

Step 2: Renders a grid of 9 driver-type cards (ModbusTcp, AbCip, AbLegacy, S7, TwinCat, FOCAS, OpcUaClient, Galaxy, Historian.Wonderware). Each card is a <a href="/clusters/@ClusterId/drivers/new/<type-slug>"> linking to the typed new-form route. Type slug = lowercase driver-type string (e.g. modbustcp → keep human-readable; map slug → DriverType enum-string in a static dictionary in this file).

Step 3: Card content: driver type name, one-line description, an icon (text symbol fine — [M], [7], [OPC], etc., no new images this PR).

Step 4: <ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" /> for consistency with peer pages.

Step 5: Build clean. Commit. feat(adminui): add DriverTypePicker landing page

Task 3.2: DriverEditRouter.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 3.1

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor

Step 1: @page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" — same edit route as today's DriverEdit.razor (will collide; resolve in Task 3.3).

Step 2: In OnInitializedAsync: load the DriverInstance row, read its DriverType string.

Step 3: Map DriverType → component type via a static dictionary literal _componentMap:

private static readonly IReadOnlyDictionary<string, Type> _componentMap = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase) {
    ["ModbusTcp"] = typeof(ModbusDriverPage),
    ["AbCip"] = typeof(AbCipDriverPage),
    ["AbLegacy"] = typeof(AbLegacyDriverPage),
    ["S7"] = typeof(S7DriverPage),
    ["TwinCat"] = typeof(TwinCatDriverPage),
    ["Focas"] = typeof(FocasDriverPage),
    ["OpcUaClient"] = typeof(OpcUaClientDriverPage),
    ["Galaxy"] = typeof(GalaxyDriverPage),
    ["Historian.Wonderware"] = typeof(HistorianWonderwareDriverPage),
};

Step 4: Render <DynamicComponent Type="_componentMap[_driverType]" Parameters="_params" /> where _params = new Dictionary<string, object?> { ["ClusterId"] = ClusterId, ["DriverInstanceId"] = DriverInstanceId }.

Step 5: Until the typed pages exist (Phase 4), the map is empty + this page falls back to a "not yet implemented for type X" notice. Keep route collision deferred until Task 3.3.

Step 6: Build clean. Commit. feat(adminui): add DriverEditRouter dispatch page

Task 3.3: Resolve route collision — delete old new-route, keep old edit-route until Phase 5

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (depends on 3.1)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor

Step 1: Delete line 1 (@page "/clusters/{ClusterId}/drivers/new"). Keep line 2 (@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"). Until Task 3.4 unhooks it too, the old generic edit page still owns the edit route — DriverEditRouter.razor from Task 3.2 stays inert (build fine, but unreachable).

Step 2: Build clean.

Step 3: Smoke test: /clusters/<id>/drivers/new now hits DriverTypePicker.razor. /clusters/<id>/drivers/<existing> still hits DriverEdit.razor.

Step 4: Commit. refactor(adminui): hand /drivers/new to DriverTypePicker

Task 3.4: Hand /drivers/{id} from DriverEdit.razor to DriverEditRouter.razor

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (depends on 3.3)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor

Step 1: Delete the remaining @page directive — file no longer routes. The router from Task 3.2 owns the route. The DriverEdit .razor file stays on disk as a referenceable component used as a fallback inside the router until all 9 typed pages land (Phase 4).

Step 2: Update DriverEditRouter.razor _componentMap so any driver type not yet implemented falls back to typeof(DriverEdit) — passing parameters identically. This keeps every existing driver row editable through whichever editor (typed or generic) is available at the time the row is opened.

Step 3: Build clean.

Step 4: Smoke test: open /clusters/<id>/drivers/<existing-modbus-row>. Router dispatches → falls back to DriverEdit.razor (since ModbusDriverPage doesn't exist yet) → page renders as before.

Step 5: Commit. refactor(adminui): route /drivers/{id} through DriverEditRouter


Phase 4 — Typed driver pages (one per driver)

Pattern (apply to every task in this phase):

Each <Type>DriverPage.razor is a self-contained page with:

  1. Route(s):
    • @page "/clusters/{ClusterId}/drivers/new/<type-slug>" (new path)
    • The router (Task 3.2) dispatches the edit case — the page itself does NOT declare the edit route; it accepts [Parameter] public string? DriverInstanceId { get; set; } and the router passes it.
  2. Wraps everything in <DriverFormShell> (Task 2.1).
  3. Top: <DriverIdentitySection> (Task 2.2).
  4. Middle: a <section class="panel"> per logical group of driver options. Each <InputText> / <InputNumber> / <InputSelect> is explicitly bound to a property on a form model (<Type>FormModel) inside @code. Field labels + help text come from the [Display(Name, Description, GroupName)] attributes on the Options class — but read via ModelMetadata, NOT via reflection at render time. (Implementation hint: use a static helper static string Label<T>(Expression<Func<T,object?>> path) that pops [Display] off at compile-time — simpler is to just hard-code the label in markup and treat [Display] as the redundant runtime hint for the API/validator. Hard-coding labels is the chosen path — keep the page Razor explicit.)
  5. Below: <DriverTestConnectButton DriverType="<Type>" GetConfigJson="@BuildConfigJson" TimeoutSeconds="@_form.ProbeTimeoutSeconds" /> (real component lands in Phase 7; for Phase 4 ship a stub component that just disables the button and shows "Available after Phase 7" — defined in Phase 7 Task 7.5).
  6. Below: <DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Enabled" /> (only visible in edit mode, not new — stub lands in Phase 6).
  7. Below: <DriverResilienceSection> (Task 2.3).
  8. Tag picker: opens <DriverTagPicker> modal with the driver's picker body (Phase 9).
  9. Save path: serializes the typed form model to JSON via JsonSerializer.Serialize(_form.Config, _jsonOpts), normalizes (same as today's NormalizeJson), upserts the DriverInstance row with RowVersion opt-concurrency. Match the existing save flow in DriverEdit.razor:187-257 line-for-line for the upsert mechanics.
  10. Load path: if DriverInstanceId != null, load row, JsonSerializer.Deserialize<<Type>Options>(row.DriverConfig, _jsonOpts) where _jsonOpts = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }. Wrap into the form model.
  11. After save, navigate to /clusters/{ClusterId}/drivers (the list page — match existing behavior).

Per-driver task acceptance:

  • Page compiles, routes resolve.
  • For a new instance: typed form save round-trips to DB; row's DriverType is set; DriverConfig JSON contains every field shown in the form.
  • For an edit: page loads existing row; every field populates; save preserves all fields.
  • Update DriverEditRouter.razor _componentMap to point this driver type at the new page.
  • Update ClusterDrivers.razor (the list page) — no change needed; it already links via the unified edit route.
  • Add a unit test in tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>DriverPageFormSerializationTests.cs round-tripping <Type>Options ↔ JSON ↔ form-model ↔ <Type>Options. Use Shouldly.

Task 4.1: ModbusDriverPage.razor

Classification: standard Estimated implement time: ~5 min Parallelizable with: 4.2 4.9

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor (_componentMap)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj (add ProjectReference to Driver.Modbus.Contracts)

Follow Phase 4 pattern. Modbus is the largest options class (289 lines); group into panels: Transport (endpoint, port, unit ID), Polling (interval, batch sizes), Probe (probe options + ProbeTimeoutSeconds), Tuning (timeouts, retries). Read ModbusDriverOptions.cs first to enumerate every property.

Task 4.2: AbCipDriverPage.razor

Classification: standard Estimated implement time: ~5 min Parallelizable with: 4.1, 4.3 4.9

Files: as 4.1, swap ModbusAbCip. Add ProjectReference to Driver.AbCip.Contracts. Read AbCipDriverOptions.cs to enumerate fields. PLC family selector (CompactLogix / ControlLogix) is a key field.

Task 4.3: AbLegacyDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.1, 4.2, 4.44.9 Files: as 4.1 for AbLegacy. Read AbLegacyDriverOptions.cs.

Task 4.4: S7DriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.3, 4.54.9 Files: as 4.1 for S7. CPU/rack/slot tuple in a Connection panel.

Task 4.5: TwinCatDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.4, 4.64.9 Files: as 4.1 for TwinCAT. AMS NetId + port in a Connection panel.

Task 4.6: FocasDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.5, 4.74.9 Files: as 4.1 for FOCAS. CNC series + connection params.

Task 4.7: OpcUaClientDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.6, 4.8, 4.9 Files: as 4.1 for OpcUaClient. Security profile (None / Basic256Sha256-Sign / Basic256Sha256-SignAndEncrypt) is a dropdown sourced from the same enum the OPC UA Server uses (cross-ref docs/security.md). Username/password are [DataType(DataType.Password)].

Task 4.8: GalaxyDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.7, 4.9 Files: as 4.1 for Galaxy. Two panels: mxaccessgw (gateway endpoint, API key — password input), Galaxy (ClientName, SQL config db connection if exposed in options). API key field is [DataType(DataType.Password)].

Task 4.9: HistorianWonderwareDriverPage.razor

Classification: standard · Estimated implement time: ~5 min · Parallelizable with: 4.14.8 Files: as 4.1 for the Wonderware Historian (the page covers the driver's view of historian client options — the WonderwareHistorianClientOptions lives in the .Client.Contracts project from Task 1.9). The driver type-string in DriverInstance is Historian.Wonderware.


Phase 5 — Delete the generic DriverEdit.razor

Task 5.1: Remove DriverEdit.razor + fallback in router

Classification: small Estimated implement time: ~3 min Parallelizable with: none (depends on all 4.x tasks)

Files:

  • Delete: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor (remove fallback)

Step 1: Confirm _componentMap in the router has all 9 driver types. Delete the "fallback to DriverEdit" branch.

Step 2: git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor.

Step 3: Build clean. dotnet test ZB.MOM.WW.OtOpcUa.slnx — clean.

Step 4: Smoke test all 9 driver types: open the list page, open one existing row of each type, verify the typed page renders. (For types without existing rows on dev DB, create one via the type picker first.)

Step 5: Commit. refactor(adminui): retire generic DriverEdit.razon (typed pages cover all 9 drivers)


Phase 6 — Live status panel

Task 6.1: DriverHealthChanged DPS message

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 6.2 (different folders)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverHealthChanged.cs

Step 1: Define record public sealed record DriverHealthChanged(string ClusterId, string DriverInstanceId, string State, DateTime? LastSuccessUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);State is the DriverState enum string (matches Healthy / Connecting / Faulted / Unknown used by DriverHealth).

Step 2: Add [MemoryPackable] if peer messages in this folder use MemoryPack — match the folder's existing pattern. (Look at src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs for the canonical pattern.)

Step 3: Build clean. Commit. feat(messages): add DriverHealthChanged DPS contract

Task 6.2: Publish DriverHealthChanged from each driver actor

Classification: high-risk Estimated implement time: ~5 min (per driver; bundle = ~15 min — split this into 6.2a6.2d if implementer balks) Parallelizable with: none (touches every driver actor)

Files:

  • Modify: each <Type>Driver.cs — find the place that updates _health (e.g. FocasDriver.cs:565+, ModbusDriver.cs:1180+). After every Volatile.Write(ref _health, ...), also publish to DPS topic driver-health-{ClusterId} via the injected DistributedPubSub.Get(_actorSystem).Mediator.
  • Find pattern: Volatile.Write(ref _health, — every occurrence in src/Drivers/**/*.cs not under obj/bin.

Step 1: Inject the publish callback into each driver. The cleanest hook is IDriverHealthPublisher (new interface in Core.Abstractions), with the Akka-backed impl living in Runtime and DI-registered there. Driver constructors take IDriverHealthPublisher (nullable for backward compat in tests).

Step 2: After each _health write, call _healthPublisher?.Publish(new DriverHealthChanged(...)). Pull ClusterId + DriverInstanceId from the driver's existing identity (every driver already knows its instance ID for telemetry tags).

Step 3: Add ErrorCount5Min tracking: a sliding-window counter on the driver — bump on every transition into Faulted, decay over 5min. Simple impl: a Queue<DateTime> guarded by lock; on read, dequeue entries older than 5min and return .Count.

Step 4: dotnet build clean. dotnet test clean. Driver unit tests may need a no-op IDriverHealthPublisher (provide one in Core.Abstractions: public sealed class NullDriverHealthPublisher : IDriverHealthPublisher { public void Publish(DriverHealthChanged _) { } }).

Step 5: Commit. feat(drivers): publish DriverHealthChanged to DPS on every health transition

Task 6.3: DriverStatusHub

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 6.4

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusHub.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs (register hub)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubRouteBuilderExtensions.cs (MapHub<DriverStatusHub>("/hubs/driverstatus"))

Step 1: Hub with one method: Task JoinDriver(string driverInstanceId) — adds the connection to a group named driver:{driverInstanceId}, immediately invokes Clients.Caller.SendAsync("status", currentSnapshot). Inject IDriverStatusSnapshotStore (new — Task 6.4) to read the current snapshot.

Step 2: Hub method-name constant: public const string MethodName = "status";.

Step 3: [Microsoft.AspNetCore.Authorization.Authorize] on the class (same auth as the existing AdminUI hubs).

Step 4: Build clean. Commit. feat(adminui): add DriverStatusHub

Task 6.4: DriverStatusSignalRBridge + snapshot store

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 6.3

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverStatusSignalRBridge.cs
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/HubServiceCollectionExtensions.cs (spawn bridge actor on admin-role startup; DI-register snapshot store as singleton)

Step 1: Bridge = Akka ReceiveActor. PreStart subscribes to DPS topic driver-health-* (or one topic + per-cluster filter — pick the same wildcard convention FleetStatusSignalRBridge uses). On DriverHealthChanged msg: writes to IDriverStatusSnapshotStore (latest-snapshot-wins, keyed by instance ID), then _hub.Clients.Group($"driver:{msg.DriverInstanceId}").SendAsync(DriverStatusHub.MethodName, msg).

Step 2: InMemoryDriverStatusSnapshotStore: ConcurrentDictionary<string, DriverHealthChanged> _byInstance;Upsert(msg) and TryGet(instanceId, out msg).

Step 3: Wire bridge in AddOtOpcUaSignalRBridges (or equivalent). Singleton snapshot store. Build clean.

Step 4: Commit. feat(adminui): add DriverStatusSignalRBridge + snapshot store

Task 6.5: DriverStatusPanel.razor

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (consumes 6.3 + 6.4)

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor

Step 1: Parameters: DriverInstanceId, Enabled. Inject NavigationManager for hub URL building, IServiceProvider for hub-connection auth.

Step 2: OnInitializedAsync: if Enabled == false, render "Disabled — not deployed" + skip the hub. Else build a HubConnection (HubConnectionBuilderWithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))WithAutomaticReconnect()Build()), register On<DriverHealthChanged>("status", ...), StartAsync, then InvokeAsync("JoinDriver", DriverInstanceId). The handler updates _snapshot + StateHasChanged.

Step 3: Render: state chip (color-mapped: Healthy green, Connecting yellow, Faulted red, Unknown gray) + "last success {humanized timestamp}" + ErrorCount5Min badge + collapsible "last error" panel showing LastError if set. Visual reuses existing chip / panel styles from sibling pages.

Step 4: _lastSnapshotAt = DateTime.UtcNow on each push; if (now - _lastSnapshotAt) > 30s (timer-driven re-render every 5s), add dim class to the whole panel.

Step 5: DisposeAsync: await _hub.DisposeAsync(). Implement IAsyncDisposable.

Step 6: Wire into all 9 driver pages. In edit mode (i.e. DriverInstanceId != null), render <DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_form.Identity.Enabled" /> above the resilience section.

Step 7: Build clean. Smoke test: bring up lmxopcua-fix up modbus, deploy a Modbus driver pointing at the sim, observe Healthy push within seconds. Stop the sim, observe Faulted push within the driver's poll interval. Commit. feat(adminui): live driver status panel on every driver page


Phase 7 — Test Connect

Task 7.1: IDriverProbe interface + TestDriverConnect message

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 7.2

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnectResult.cs

Step 1: IDriverProbe:

public interface IDriverProbe
{
    /// <summary>Driver-type string this probe handles (matches DriverInstance.DriverType).</summary>
    string DriverType { get; }
    /// <summary>Run a connection probe. Never mutates; never writes.</summary>
    Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);

Step 2: TestDriverConnect(string DriverType, string ConfigJson, int TimeoutSeconds, Guid CorrelationId) and TestDriverConnectResult(bool Ok, string? Message, double? LatencyMs, Guid CorrelationId). Match the MemoryPack conventions of peer messages in Messages/Admin/.

Step 3: Build clean. Commit. feat(messages,abstractions): add IDriverProbe + TestDriverConnect contract

Task 7.2: AdminOperationsActor handler for TestDriverConnect

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 7.1 (separate file; modify late)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs

Step 1: Add ctor param IEnumerable<IDriverProbe> probes. Build _probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase) in ctor. Update Props factory accordingly.

Step 2: ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync). Handler:

private async Task HandleTestDriverConnectAsync(TestDriverConnect msg)
{
    var replyTo = Sender;
    if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
    {
        replyTo.Tell(new TestDriverConnectResult(false, $"No probe registered for driver type '{msg.DriverType}'", null, msg.CorrelationId));
        return;
    }
    var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(clampedSec));
    try
    {
        var sw = Stopwatch.StartNew();
        var result = await probe.ProbeAsync(msg.ConfigJson, TimeSpan.FromSeconds(clampedSec), cts.Token);
        replyTo.Tell(new TestDriverConnectResult(result.Ok, result.Message, sw.Elapsed.TotalMilliseconds, msg.CorrelationId));
    }
    catch (OperationCanceledException)
    {
        replyTo.Tell(new TestDriverConnectResult(false, $"Probe timed out after {clampedSec}s", null, msg.CorrelationId));
    }
    catch (Exception ex)
    {
        _log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
        replyTo.Tell(new TestDriverConnectResult(false, ex.Message, null, msg.CorrelationId));
    }
}

Step 3: Update wherever AdminOperationsActor.Props(...) is called (search the repo) to pass the new probes enumerable. Likely in Runtime DI registration — register all IDriverProbe impls then resolve IEnumerable<IDriverProbe> for the singleton.

Step 4: Build clean. Commit. feat(adminops): handle TestDriverConnect via per-driver IDriverProbe

Task 7.3: TCP-only probe impls (Modbus, AbCip, AbLegacy, S7)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 7.4

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs

Step 1: Each impl deserializes its own Options class from the configJson, extracts Endpoint (host:port — exact field name depends on the Options class), opens a TCP Socket with ConnectAsync(host, port, ct), closes immediately. On success: (true, null, sw.Elapsed). On SocketException: (false, ex.SocketErrorCode.ToString(), null).

Step 2: Register each as services.AddSingleton<IDriverProbe, ModbusDriverProbe>() (and peers) in the driver's existing *FactoryExtensions.cs Add* method.

Step 3: Build clean. Commit. feat(drivers): TCP-only probes for Modbus, AbCip, AbLegacy, S7

Task 7.4: Specialty probes (FOCAS, TwinCAT, OpcUaClient, Galaxy, Historian.Wonderware)

Classification: standard Estimated implement time: ~5 min (per driver; bundle ~15 min — may need to split into 7.4a7.4e) Parallelizable with: Task 7.3

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs — calls FOCAS cnc_allclibhndl3 connect + immediate cnc_freelibhndl. Reuses the existing IFocasClient.Connect.
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs — opens an AmsAddress and sends an ADS Read State request.
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs — opens an OPCFoundation.NetStandard.Opc.Ua.Client.Session against the configured endpoint with the configured security profile, immediately closes.
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/GalaxyDriverProbe.cs — sends MxCommand.Ping to mxaccessgw via the existing gRPC client. (Build on the existing Health/ folder.)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/HistorianWonderwareDriverProbe.cs — TCP probe to historian endpoint (historian client uses an MX-style transport; cheap path is a TCP connect to the historian's IPC port + close).

Step 1: Each impl registers in its driver's Add* extension.

Step 2: Build clean. dotnet test clean. Commit. feat(drivers): specialty Test Connect probes for FOCAS/TwinCAT/OPCUA/Galaxy/Historian

Task 7.5: DriverTestConnectButton.razor

Classification: small Estimated implement time: ~4 min Parallelizable with: none (consumes 7.2 + 7.3 + 7.4)

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs

Step 1: AdminProbeService — thin wrapper around IAdminOperationsClient. Method Task<TestDriverConnectResult> TestAsync(string driverType, string configJson, int timeoutSeconds, CancellationToken ct). Builds the message + Asks + applies a Task.WhenAny 65s timeout wall as outer guard. DI-register as scoped.

Step 2: Button component params: DriverType, GetConfigJson (Func<string>), TimeoutSeconds (int). Renders <button class="btn btn-outline-primary btn-sm">Test Connect</button> + inline result chip (green tick + latency, or red x + message). Spinner during in-flight. Auto-clears chip after 30s.

Step 3: On click: invokes AdminProbeService.TestAsync(DriverType, GetConfigJson(), TimeoutSeconds, ct) with CancellationToken.None (the actor-side timeout already bounds it).

Step 4: Wire into all 9 driver pages by replacing the Phase 4 stub.

Step 5: Build clean. Smoke test: open /clusters/<id>/drivers/new/modbustcp, type sim endpoint into form, click Test Connect → green. Wrong port → red within 5s. Black-holed IP → "Probe timed out after 5s".

Step 6: Commit. feat(adminui): Test Connect button on every driver page


Phase 8 — Reconnect / Restart

Task 8.1: RestartDriver + ReconnectDriver messages + AdminOperationsActor handlers

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 8.2 (separate files)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs
  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/ReconnectDriver.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs

Step 1: Messages: RestartDriver(string ClusterId, string DriverInstanceId, string ActorByUserName, Guid CorrelationId) + RestartDriverResult(bool Ok, string? Message, Guid CorrelationId). Same shape for Reconnect*.

Step 2: Handlers in the actor. They locate the running driver actor (the existing DriverHostActor hierarchy already addresses driver actors by instance ID — find the existing lookup mechanism in DriverHostActor.cs / Runtime and reuse it). Reconnect = Tell the driver actor a Reconnect internal command; Restart = Tell its supervisor to stop+restart the child.

Step 3: Audit-log every call via the existing ConfigEdits mechanism — entity type DriverInstance, fields {op: restart|reconnect}.

Step 4: Build clean. Commit. feat(adminops): Restart/Reconnect driver operations

Task 8.2: DriverOperator authorization policy

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 8.1

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/ — add a DriverOperator policy alongside the existing WriteOperate / WriteTune policies. Map to LDAP group ot-driver-operator (or document the chosen group name in docs/Security.md).
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ — register the policy with AddAuthorizationBuilder().AddPolicy("DriverOperator", p => p.RequireRole("ot-driver-operator")) (or whatever pattern the existing AdminUI policies use).
  • Modify: docs/Security.md — add a row to the role/policy table.

Step 1: Mirror the shape of the most-similar existing policy (probably WriteOperate).

Step 2: Build clean. Commit. feat(security): add DriverOperator authorization policy

Task 8.3: Wire Reconnect/Restart buttons into DriverStatusPanel

Classification: small Estimated implement time: ~4 min Parallelizable with: none (depends on 8.1 + 8.2 + 6.5)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor

Step 1: Inject IAuthorizationService. In OnInitializedAsync, check AuthorizeAsync(user, null, "DriverOperator") → set _canOperate bool. Render buttons only when _canOperate && Enabled.

Step 2: Two buttons:

  • Reconnect — no confirm. Click: spinner on button, set inline "Reconnecting…" chip, invoke AdminOperationsClient.AskAsync<ReconnectDriverResult>(new ReconnectDriver(...)). Result chip clears once next DriverHealthChanged push arrives.
  • Restart — confirm dialog "Restart driver <id>? This briefly interrupts subscriptions." Same flow otherwise.

Step 3: Both buttons disabled (greyed-out) during in-flight ops or during a Test Connect on the same page (publish a simple page-scoped bool _busyAnything via the parent driver page → flows to the panel via a parameter).

Step 4: Build clean. Smoke test: Reconnect → see Connecting → Healthy transition push in the panel. Restart → confirm → see actor restart. Commit. feat(adminui): Reconnect/Restart on DriverStatusPanel (DriverOperator-gated)


Phase 9 — Static address pickers

Task 9.1: DriverTagPicker.razor modal shell

Classification: small Estimated implement time: ~4 min Parallelizable with: 9.29.10

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTagPicker.razor

Step 1: Modal shell. Params: Visible (bool), OnClose (EventCallback), Title (string, e.g. "Modbus address"), ChildContent (RenderFragment), OnPickAddress (EventCallback<string>). Renders a Bootstrap-style .modal.show (no JS interop — Razor-managed visibility class). The child fragment is the per-driver picker body.

Step 2: Includes a search box + "Use this address" button at the bottom; "Use" calls OnPickAddress with the value currently bound in the child.

Step 3: Build clean. Commit. feat(adminui): DriverTagPicker modal shell

Task 9.2 9.10: Per-driver static picker bodies (9 tasks)

Classification: small Estimated implement time: ~3 min each Parallelizable with: each other (different files)

For each driver, create src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/<Type>AddressPickerBody.razor. Per Section 6.2 of the design:

Task Driver Picker body
9.2 Modbus Register-type dropdown (Coil/DiscreteInput/Holding/Input) + offset spinner + length → renders 4x00001-4.
9.3 AbCip Tag name + element index; CompactLogix/ControlLogix hint from form.
9.4 AbLegacy File type (N/B/F/I/O/S/T/C/R) + file number + element.
9.5 S7 Area (DB/M/I/Q) + db-number + offset + S7 type → DB10.DBD20:REAL.
9.6 TwinCat Free-text ADS variable name + format hint.
9.7 FOCAS Parameter group dropdown + parameter ID; drives the FOCAS function-code lookup table.
9.8 OpcUaClient Free-text NodeId field. (Live browse deferred — Section 10.)
9.9 Galaxy Free-text tag_name.AttributeName field. (Live browse deferred.)
9.10 Historian.Wonderware Tag name + retrieval mode + interval.

Each task:

Step 1: Create the per-driver picker body component.

Step 2: Add a small unit test in tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/<Type>AddressBuilderTests.cs — for the static address builders that compute a string (Modbus, S7, FOCAS), assert input → string output. For free-text bodies (OpcUaClient, Galaxy), test pass-through.

Step 3: Wire into the matching *DriverPage.razor — add a "Pick address" button that toggles <DriverTagPicker> open with this body as its child.

Step 4: Build clean. Commit per task. feat(adminui): <Type> address picker


Phase 10 — End-to-end verification

Task 10.1: DriverTestConnectE2eTests

Classification: standard Estimated implement time: ~5 min Parallelizable with: 10.2, 10.3

Files:

  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverTestConnectE2eTests.cs

Step 1: Use the existing Docker fixture pattern from peer tests in this project. Three test methods: Modbus, AbCip, S7 — each starts the corresponding lmxopcua-fix up <driver> fixture (the test project's fixture base class handles it) + asserts green probe vs sim, red probe vs wrong port, timeout vs 1.2.3.4:502 (black-holed).

Step 2: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter DriverTestConnectE2eTests — passes.

Step 3: Commit. test(adminui): E2E Test Connect probes against Docker sims

Task 10.2: DriverReconnectE2eTests

Classification: standard Estimated implement time: ~5 min Parallelizable with: 10.1, 10.3

Files:

  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverReconnectE2eTests.cs

Step 1: Start a Modbus driver against the sim, observe Healthy, dispatch ReconnectDriver via the in-cluster admin ops client, assert Connecting → Healthy transitions within 5s.

Step 2: Build + run. Commit. test(adminui): E2E Reconnect operation

Task 10.3: DriverStatusHubE2eTests

Classification: standard Estimated implement time: ~5 min Parallelizable with: 10.1, 10.2

Files:

  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverStatusHubE2eTests.cs

Step 1: Open a SignalR connection to /hubs/driverstatus, invoke JoinDriver, force a DriverHealthChanged via test seam (publish directly to the DPS topic), assert push received within 1s.

Step 2: Build + run. Commit. test(adminui): E2E DriverStatusHub push

Task 10.4: Manual smoke checklist (documented, not automated)

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none

Files:

  • Modify: docs/plans/2026-05-28-adminui-driver-pages-design.md — replace Section 8.3 stub with the actual checklist as run, with timestamps.

Step 1: Run the checklist (Section 8.3 of the design). Tick each item.

Step 2: Commit. docs(plans): record AdminUI driver pages smoke-test results


Out-of-scope (documented follow-ups)

These are NOT part of this plan. Capture as separate work items after merge:

  • Live OPC UA browse in OpcUaClient picker.
  • Live Galaxy hierarchy browse in Galaxy picker.
  • Historian.Wonderware tag list pulled from the historian store.
  • DriverStatusPanel history graphs + per-tag diagnostics.
  • Per-driver bespoke controls beyond Reconnect/Restart.
  • Polly resilience config typed-form (still a JSON textarea this PR).

Cross-cutting verification (run before final PR)

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx — clean.
  2. dotnet test ZB.MOM.WW.OtOpcUa.slnx — clean.
  3. lmxopcua-fix up modbus, then run the manual smoke (10.4).
  4. Review git diff --stat master.. — confirm scope matches plan (no surprise file changes).
  5. Confirm OtOpcUa-docs-issues.md shows no new XML-doc warnings introduced by the new code (run commentchecker-aot on the AdminUI + Drivers/* trees).