- 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.
20 KiB
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:
- Replace the JSON blob with a typed form per driver type that exposes every supported configuration option.
- Add three driver-aware operator capabilities to each page: Test Connect, live runtime status, and a driver-specific tag/address picker.
- 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— existingClusterDrivers.razorlist, unchanged./clusters/{ClusterId}/drivers/new— newDriverTypePicker.razor(operator picks driver type)./clusters/{ClusterId}/drivers/new/{driverType}— typed new-form for that type./clusters/{ClusterId}/drivers/{DriverInstanceId}—DriverEditRouter.razorreads the row'sDriverType, dispatches to the right*DriverPagevia<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>viaDataAnnotationsValidator.[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 inCore.Abstractions(or equivalent). One implementation per driver type, lives in the driver's runtime project. Reuses the existingIHostConnectivityProbeplumbing where present (FOCAS, TwinCAT confirmed). For drivers without one, the probe is a cheap subset of the real connect path: TCPSocketAsyncOperationsfor Modbus/AbCip/S7, session open+close for OpcUaClient,MxCommand.Pingfor Galaxy. Probes never write.TestDriverConnectmessage inCommons/Messages/Admin—(string driverType, string configJson, TimeSpan timeout). Handler inAdminOperationsActor: resolves the right probe via keyed DI (IServiceProvider.GetRequiredKeyedService<IDriverProbe>(driverType)), deserializes JSON into the matching Options class, callsprobe.RunAsync(options, ct). ReturnsTestDriverConnectResult(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 methodJoinDriver(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 bydriverInstanceId. Only running drivers publish;Enabled=falseinstances render "Disabled — not deployed" without subscribing.<DriverStatusPanel>— propsDriverInstanceId,Enabled. Opens hub on init, callsJoinDriver, registersOn<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 existingAuthentication.Ldapconfig). Hidden (not just disabled) for unauthorized users — same approach as other AdminUI gated actions. - Dispatch
RestartDriver/ReconnectDrivermessages throughAdminOperationsActor, 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.CLIbrowse 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/ListAttributesvia gRPC. Returnstag_name.AttributeName. ReusesIGalaxyHierarchySource. - 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 eachDriverState, disabled mode, stale-data dim.DriverRestartReconnectAuthorizationTests— buttons hidden withoutDriverOperatorpolicy.- 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 modbusetc.). Green probe vs sim, red probe vs wrong port, timeout vs black-holed IP.DriverReconnectE2eTests— start a driver, click Reconnect, assertConnecting → Healthytransition 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:
-
Pre-flight:
lmxopcua-fix up modbus standard— Modbus sim running on10.100.0.35:5020.- AdminUI deployed and reachable.
- LDAP user has the
DriverOperator(orFleetAdmin) role.
-
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.
- Navigate to
-
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".
- Fill in Host=
-
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.
- Set valid endpoint back. Save. Verify redirect to
-
Live status panel:
- In a second browser tab, open the same driver's edit page. Confirm the
DriverStatusPanelrenders 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.
- In a second browser tab, open the same driver's edit page. Confirm the
-
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
DriverOperatorrole).
-
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.
-
Other 8 driver types — smoke each page renders:
- Repeat steps 2–4 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an
opc.tcp://endpoint.
- Repeat steps 2–4 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an
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:
- Land 9 Contracts projects + move Options classes. No UI changes.
- Land shared section components (
DriverIdentitySection,DriverResilienceSection,DriverFormShell). Wire into existingDriverEdit.razorfirst so they're tested in place. - Land
DriverTypePicker+DriverEditRouter+<DynamicComponent>dispatch. - 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.
- Delete the generic
DriverEdit.razor+ its route once all 9 typed pages exist. - Land
DriverStatusHub+ bridge +<DriverStatusPanel>(read-only first). - Land
<DriverTestConnectButton>+IDriverProbeimpls + AdminOperationsActor handler. - Land Reconnect/Restart on the status panel with
DriverOperatorpolicy. - 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.