docs(plans): AdminUI driver-specific pages design

Replaces the generic JSON-blob DriverEdit page with typed per-driver
pages (all 9 drivers), Test Connect, live status panel with
Reconnect/Restart, and a per-driver tag/address picker. Live OPC UA +
Galaxy browse explicitly deferred to a follow-up.
This commit is contained in:
Joseph Doherty
2026-05-28 08:29:20 -04:00
parent 64e3fbe035
commit af06648558
@@ -0,0 +1,272 @@
# 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 `ProjectReference`s — 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 (documented; run before PR ship)
1. `lmxopcua-fix up modbus`.
2. Create a Modbus driver via the new page, Test Connect → green.
3. Status panel in second browser tab; click Reconnect in first; observe push in second.
4. Repeat for Galaxy (mxaccessgw) and OPC UA reference server.
### 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.