Files
lmxopcua/docs/plans/2026-05-28-adminui-driver-pages-design.md
T
Joseph Doherty 494da22cd1 test(adminui): E2E scaffolding for Test Connect + Reconnect + Status hub
- DriverTestConnectE2eTests: 3 scenarios (sim/wrong-port/black-hole)
  against the Modbus Docker fixture. Sim + wrong-port skip if fixture
  unreachable; black-hole uses ModbusDriverProbe directly (no fixture).
- DriverReconnectE2eTests: message round-trip through AdminOperationsActor
  cluster singleton — Ok=true + audit write, without live driver side effect.
- DriverStatusHubE2eTests: bridge-mocked fallback — spawns
  DriverStatusSignalRBridge in the harness ActorSystem with a mock
  IHubContext, publishes DriverHealthChanged to the driver-health DPS
  topic, asserts store upsert + hub SendAsync call.
- DockerFixtureAvailability helper: TCP-connect probe for skip guards.
- Moq 4.20.72 added to central package management for hub mocking.
- Design doc §8.3 replaced with concrete pre-ship operator runbook.
2026-05-28 11:31:12 -04:00

20 KiB
Raw Blame History

AdminUI — Driver-Specific Pages

Status: Design approved, ready for implementation planning Date: 2026-05-28 Branch: master (work to land on a feature branch)

1. Motivation

Today the AdminUI has a single generic DriverEdit.razor page (src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor, 323 lines) that edits every driver type via a raw JSON DriverConfig textarea. The page itself flags this as temporary:

Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred… lands in a Phase C.2 follow-up.

This design is that follow-up. Goals:

  1. Replace the JSON blob with a typed form per driver type that exposes every supported configuration option.
  2. Add three driver-aware operator capabilities to each page: Test Connect, live runtime status, and a driver-specific tag/address picker.
  3. Add Reconnect / Restart controls on the status panel for authorized users.

2. Scope

All 9 driver types ship typed pages in this work:

ModbusTcp, AbCip, AbLegacy, S7, TwinCat, FOCAS,
OpcUaClient, Galaxy, Historian.Wonderware

Each typed page exposes the full surface of its driver's options class — the JSON editor is retired; the typed form is the only way to edit driver config from the AdminUI.

3. Architecture

3.1 Project layout

src/Drivers/
  ZB.MOM.WW.OtOpcUa.Driver.<Type>/                     (runtime, unchanged behavior)
  ZB.MOM.WW.OtOpcUa.Driver.<Type>.Contracts/           NEW — POCO options + DataAnnotations only
    <Type>DriverOptions.cs                              (moved from runtime project)

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/
  Components/Pages/Clusters/Drivers/                    NEW folder
    DriverTypePicker.razor                              route: /clusters/{id}/drivers/new
    DriverEditRouter.razor                              route: /clusters/{id}/drivers/{instanceId}
    ModbusDriverPage.razor                              route: /clusters/{id}/drivers/new/modbus
    GalaxyDriverPage.razor                              route: /clusters/{id}/drivers/new/galaxy
    S7DriverPage.razor
    OpcUaClientDriverPage.razor
    AbCipDriverPage.razor
    AbLegacyDriverPage.razor
    TwinCatDriverPage.razor
    FocasDriverPage.razor
    HistorianWonderwareDriverPage.razor
  Components/Shared/Drivers/                            NEW folder
    DriverFormShell.razor                               panel layout + Save/Cancel/Delete
    DriverIdentitySection.razor                         InstanceId, Name, Namespace, Enabled
    DriverResilienceSection.razor                       Polly overrides
    DriverStatusPanel.razor                             live status + Reconnect/Restart
    DriverTestConnectButton.razor                       per-driver-timeout probe
    DriverTagPicker.razor                               modal shell, hosts per-driver picker body
  Hubs/
    DriverStatusHub.cs                                  NEW SignalR hub at /hubs/driverstatus
    DriverStatusSignalRBridge.cs                        NEW (mirrors FleetStatusSignalRBridge)

3.2 Routing

  • /clusters/{ClusterId}/drivers — existing ClusterDrivers.razor list, unchanged.
  • /clusters/{ClusterId}/drivers/new — new DriverTypePicker.razor (operator picks driver type).
  • /clusters/{ClusterId}/drivers/new/{driverType} — typed new-form for that type.
  • /clusters/{ClusterId}/drivers/{DriverInstanceId}DriverEditRouter.razor reads the row's DriverType, dispatches to the right *DriverPage via <DynamicComponent> (no redirect flicker).

3.3 Schema source

Each driver's Options class moves to a new Driver.<Type>.Contracts csproj — POCO + System.ComponentModel.DataAnnotations attributes only, no NuGet references, no project references. The runtime driver project adds a ProjectReference to its contracts sibling and re-uses the same type (single source of truth, no TypeForwardedTo needed if the namespace is preserved). The AdminUI gains 9 ProjectReferences — all pure POCO, so no native deps (Galaxy COM, FOCAS native libs, OPC UA stack) leak into the AdminUI publish output.

Attributes used:

  • [Required], [Range(...)], [RegularExpression(...)] — render as inputs + <ValidationMessage> via DataAnnotationsValidator.
  • [Display(Name, Description, GroupName)] — label, help-text under field, panel section.
  • [DataType(DataType.Password)] — render as <InputText type="password"> (e.g. mxaccessgw API key).

The *DriverPage.razor files explicitly bind each field (no runtime reflection). Attributes drive labels/help/validation but not field discovery — this avoids the "metadata silently drifts from rendering" trap.

3.4 Persistence

DriverInstance.DriverConfig stays a JSON string column (no schema change). On save: typed form-model serialized via System.Text.Json against the driver's Options class. On load: row's JSON deserialized into the matching Options class with JsonSerializerOptions { UnmappedMemberHandling = Skip } so old/unknown fields are silently dropped on next save. Version skew is bounded by the fact that drivers ship as one host binary.

RowVersion optimistic concurrency unchanged from today's DriverEdit.razor.

4. Test Connect

4.1 Flow

[Browser]              [AdminUI server]              [Cluster]

DriverGalaxyPage       AdminProbeService             AdminOperationsActor
   |                         |                              |
   |-- click TestConnect --->|                              |
   |                         |-- Ask<TestDriverConnect> --->|
   |                         |    (driverType, configJson,  |
   |                         |     timeoutSecs)             |
   |                         |                              |--> spawn transient probe actor
   |                         |                              |    (resolves IDriverProbe by
   |                         |                              |     driverType via DI)
   |                         |                              |<-- ProbeResult (ok, latencyMs)
   |                         |<-- TestDriverConnectResult --|
   |<-- green / red chip ----|

4.2 Components

  • IDriverProbe — interface in Core.Abstractions (or equivalent). One implementation per driver type, lives in the driver's runtime project. Reuses the existing IHostConnectivityProbe plumbing where present (FOCAS, TwinCAT confirmed). For drivers without one, the probe is a cheap subset of the real connect path: TCP SocketAsyncOperations for Modbus/AbCip/S7, session open+close for OpcUaClient, MxCommand.Ping for Galaxy. Probes never write.
  • TestDriverConnect message in Commons/Messages/Admin(string driverType, string configJson, TimeSpan timeout). Handler in AdminOperationsActor: resolves the right probe via keyed DI (IServiceProvider.GetRequiredKeyedService<IDriverProbe>(driverType)), deserializes JSON into the matching Options class, calls probe.RunAsync(options, ct). Returns TestDriverConnectResult(bool ok, string? message, TimeSpan? latency).
  • AdminProbeService (AdminUI side) — thin wrapper around the existing AdminOperationsActor bridge. Caller passes timeout; service enforces a 60s hard backstop.
  • <DriverTestConnectButton> — accepts driver type + Func<string> to build form JSON on-click. Renders button + inline result chip (auto-clears after 30s). Disabled while in-flight.

4.3 Timeout

Each driver's Options class exposes a ProbeTimeout (TimeSpan or int Seconds) with a driver-appropriate default — e.g. Modbus 5s, OpcUaClient 15s, Galaxy 30s. The button reads from the live form (not the persisted row), so an operator can override the timeout per probe attempt. Server-side max = 60s.

4.4 Safety

  • Probe spawns a transient actor with the form's config — the live driver actor (using the persisted config) is untouched.
  • Probe never mutates the live driver or the database.
  • Probe inherits the user context via the existing AdminOperationsActor audit-log entry.

5. Live Status Panel

5.1 Flow

DriverActor          DriverStatusSignalRBridge        DriverStatusPanel (browser)
  |                          ^                                |
  |-- publishes              |-- subscribed in                |
  |   DriverHealthChanged    |   OnInitializedAsync           |
  |   to event stream        |   with InstanceId filter       |
  |                          |                                |
  |                          |-- pushes update -------------->|
  |                                                            |
  |                                                            |-- renders state chip,
  |                                                            |   last-success, error count,
  |                                                            |   Reconnect/Restart buttons

5.2 Reused infrastructure

Driver actors already maintain DriverHealth(state, lastSuccessUtc, lastError) — confirmed in FOCAS (FocasDriver.cs) and TwinCAT. The bridge mirrors the existing FleetStatusSignalRBridge + AlertSignalRBridge pattern. SignalR hub uses the same cookie-auth as existing hubs.

5.3 New components

  • DriverStatusHub — single method JoinDriver(string driverInstanceId), adds connection to a per-instance group and immediately replies with the current snapshot.
  • DriverStatusSignalRBridge — subscribes to per-cluster driver-health event stream, fans out into SignalR groups keyed by driverInstanceId. Only running drivers publish; Enabled=false instances render "Disabled — not deployed" without subscribing.
  • <DriverStatusPanel> — props DriverInstanceId, Enabled. Opens hub on init, calls JoinDriver, registers On<StatusSnapshot>("status", ...). Renders state chip (Healthy / Connecting / Faulted / Unknown) + last-success timestamp ("2s ago") + error count over last 5min + last error message (collapsed, expandable). Disposes hub on dispose.

5.4 Reconnect / Restart controls

Two buttons on the status panel:

  • Reconnect — driver actor closes + reopens its transport, keeps actor alive. Fast, idempotent. No confirm dialog.
  • Restart — full actor stop + respawn, loses in-memory state. Slower, can interrupt active subscriptions. Confirm dialog required.

Both:

  • Gated by authorization policy DriverOperator (mapped to an LDAP group via existing Authentication.Ldap config). Hidden (not just disabled) for unauthorized users — same approach as other AdminUI gated actions.
  • Dispatch RestartDriver / ReconnectDriver messages through AdminOperationsActor, which audit-logs each operation.
  • Show spinner + inline "Reconnecting…" chip; panel reflects new state via the SignalR push once health changes.
  • Disabled when Enabled=false (nothing to restart) and during any in-flight Test Connect on the same page.

5.5 Out of scope this PR

History graphs (latency/error rate over time), deep diagnostics (per-tag last values, queue depths), and per-driver bespoke controls beyond Reconnect/Restart — all follow-ups.

5.6 Edge cases

  • Driver not yet deployed (row exists, Enabled=true, cluster hasn't picked it up) — panel shows "Awaiting deployment", DriverHealth.Unknown.
  • Edit page open while driver is running — status reflects deployed config, not the form. Banner: "Showing live status for the deployed config — your unsaved changes take effect after Save → next deploy cycle."
  • Test Connect + live status — probe runs in a transient actor (Section 4), live status reflects the persistent actor. Don't interfere.

6. Tag / Address Picker

A picker slot on each page, launched as a modal so the config form stays visible behind it.

6.1 Shared shell

<DriverTagPicker> — modal chrome + search box + "use this address" action that emits a string back to the parent (e.g. 4x0001 for Modbus, ns=2;s=Channel.Device.Tag for OPC UA). Where the picked address lands depends on context: from "create equipment / create tag" flow, pushed into that form; standalone, copy-to-clipboard.

6.2 Per-driver bodies — first pass (all static address builders)

Driver Picker body
Modbus Register-type dropdown + offset spinner + length → 4x00001-4
AbCip Tag name + element index, PLC-family hint from form
AbLegacy File type (N/B/F/I/O/S/T/C/R) + file number + element, PLC-family-aware
S7 Area (DB/M/I/Q) + db-number + offset + S7 type → DB10.DBD20:REAL
TwinCat ADS variable name (free-text + format hint)
FOCAS Parameter group dropdown + parameter ID; drives FOCAS function-code lookup
OpcUaClient Static helper (NodeId free-text) — live browse deferred
Galaxy Static helper (tag_name.AttributeName free-text) — live browse deferred
Historian.Wonderware Tag name + retrieval mode + interval

6.3 Deferred to follow-up

  • OpcUaClient live browse — open session against configured endpoint, walk address space, return NodeId. Reuses the existing Client.CLI browse path or calls the OPC UA stack inline. Requires endpoint config to be valid (Test Connect first).
  • Galaxy live browse — calls mxaccessgw's GalaxyRepository.ListObjects / ListAttributes via gRPC. Returns tag_name.AttributeName. Reuses IGalaxyHierarchySource.
  • Historian.Wonderware tag list — pull from historian's tag store.

The picker slot is wired so swapping a static builder for a live browser later is a 1-component swap, not a page rewrite.

7. Error Handling

Failure Surface
Invalid form input DataAnnotationsValidator + per-field <ValidationMessage>; Save disabled.
DbUpdateConcurrencyException Red banner — "Another user changed this driver instance, reload before re-applying." (matches existing pattern)
FK violation (Namespace deleted while edit open) Catch DbUpdateException — "Namespace <id> no longer exists in this cluster — pick another or recreate it."
Probe — driver-side exception Probe actor catches, returns (false, ex.Message, null). Red chip with message. Full stack to Serilog with audit context.
Probe — timeout (false, "Probe timed out after {n}s", null). Server-side 60s backstop.
Probe — DI lookup fails (unknown driver type) Defensive — (false, "No probe registered for driver type '{type}'", null). Error-level log.
SignalR disconnect "Reconnecting…" chip + SignalR auto-reconnect. Stale snapshot dimmed after 30s.
Reconnect/Restart on stopped driver "Driver is not running on any node". Button re-enables.
Authorization denied Reconnect/Restart buttons hidden for unauthorized users.
Corrupted DriverConfig JSON on row load Yellow banner — "Saved config could not be parsed against the current schema; falling back to defaults. Save will overwrite." Original JSON preserved in banner for copy-paste.

8. Testing

8.1 Unit tests (tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ — extend existing project, or create if absent)

  • DriverPageFormSerializationTests — 9 drivers × round-trip Options ↔ JSON ↔ form ↔ DB row. Asserts no loss for known fields, unknown fields dropped silently.
  • DriverTestConnectButtonTests — render tests: enabled/disabled states, timeout behavior, result chip.
  • DriverStatusPanelTests — render snapshots for each DriverState, disabled mode, stale-data dim.
  • DriverRestartReconnectAuthorizationTests — buttons hidden without DriverOperator policy.
  • Address-builder unit tests per driver — 9 small suites covering canonical address formats.

8.2 Integration tests (tests/Server/.../IntegrationTests/)

  • DriverTestConnectE2eTests — Modbus + AbCip + S7 against Docker fixtures (lmxopcua-fix up modbus etc.). Green probe vs sim, red probe vs wrong port, timeout vs black-holed IP.
  • DriverReconnectE2eTests — start a driver, click Reconnect, assert Connecting → Healthy transition within N seconds.
  • DriverStatusHubE2eTests — open hub, force state change, assert push arrives within 1s.

8.3 Manual smoke (run before PR ship)

Operator on the dev VM with Docker fixtures available:

  1. Pre-flight:

    • lmxopcua-fix up modbus standard — Modbus sim running on 10.100.0.35:5020.
    • AdminUI deployed and reachable.
    • LDAP user has the DriverOperator (or FleetAdmin) role.
  2. Type picker:

    • Navigate to /clusters/<id>/drivers/new. Verify 9 driver-type cards render.
    • Click "ModbusTcp". Verify the typed form opens on /clusters/<id>/drivers/new/modbustcp.
  3. Test Connect (form-driven, no save):

    • Fill in Host=10.100.0.35, Port=5020, leave defaults otherwise.
    • Click "Test Connect". Verify green chip + latency < 100ms.
    • Change port to 9999. Click again. Verify red chip with "ConnectionRefused" or similar.
    • Change host to 1.2.3.4. Click again. Within (default 5s) the chip shows "Probe timed out after 5s".
  4. Save + edit:

    • Set valid endpoint back. Save. Verify redirect to /clusters/<id>/drivers.
    • Open the just-saved instance. Verify the typed form pre-populates correctly.
  5. Live status panel:

    • In a second browser tab, open the same driver's edit page. Confirm the DriverStatusPanel renders state + last-update.
    • Stop the Modbus sim (lmxopcua-fix down modbus). Within ~30s, verify the panel transitions Healthy → Reconnecting / Faulted (depending on driver state).
    • Bring the sim back up (lmxopcua-fix up modbus standard). Verify Healthy is restored.
  6. Reconnect / Restart:

    • Click "Reconnect" on the status panel. Verify a brief "Reconnecting…" chip + a Healthy state push within 5s.
    • Click "Restart". Confirm in the dialog. Verify the actor restarts (full state transition).
    • Verify both buttons are HIDDEN for an unauthorized user (LDAP user without DriverOperator role).
  7. Address picker:

    • Click "Pick address" on the Modbus page. Verify the modal opens.
    • Builder: select Holding + offset=10 + length=2. Verify the chip shows 4x00010-2. Click "Use this address" — verify it surfaces in the parent page.
    • Close the modal. Repeat for one other driver type (e.g. S7) to confirm cross-driver wiring.
  8. Other 8 driver types — smoke each page renders:

    • Repeat steps 24 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an opc.tcp:// endpoint.

If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.

8.4 bUnit harness

If the AdminUI tests project doesn't already use bUnit, render tests downgrade to logic-only tests on the @code { } block; Razor markup is covered by integration tests. Decision deferred to implementation plan.

9. Migration / Sequencing

Incremental — driver-by-driver swap-over. Each step compile-clean and shippable on its own:

  1. Land 9 Contracts projects + move Options classes. No UI changes.
  2. Land shared section components (DriverIdentitySection, DriverResilienceSection, DriverFormShell). Wire into existing DriverEdit.razor first so they're tested in place.
  3. Land DriverTypePicker + DriverEditRouter + <DynamicComponent> dispatch.
  4. Land driver-specific pages one at a time. After each, route list-page links for that driver type only to the new page; leave others on generic editor.
  5. Delete the generic DriverEdit.razor + its route once all 9 typed pages exist.
  6. Land DriverStatusHub + bridge + <DriverStatusPanel> (read-only first).
  7. Land <DriverTestConnectButton> + IDriverProbe impls + AdminOperationsActor handler.
  8. Land Reconnect/Restart on the status panel with DriverOperator policy.
  9. Land 9 static address builders inside <DriverTagPicker>.

10. Out of scope (follow-ups)

  • Live tag browse for OpcUaClient + Galaxy (Section 6.3).
  • Historian.Wonderware tag list pulled from store.
  • Status panel history graphs + per-tag diagnostics (Section 5.5).
  • Per-driver bespoke controls beyond Reconnect/Restart.
  • bUnit setup if not already present (Section 8.4) — decide during implementation planning.