# 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./ (runtime, unchanged behavior) ZB.MOM.WW.OtOpcUa.Driver..Contracts/ NEW — POCO options + DataAnnotations only 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 `` (no redirect flicker). ### 3.3 Schema source Each driver's `Options` class moves to a new `Driver..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 + `` via `DataAnnotationsValidator`. - `[Display(Name, Description, GroupName)]` — label, help-text under field, panel section. - `[DataType(DataType.Password)]` — render as `` (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 --->| | | (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(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. - **``** — accepts driver type + `Func` 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. - **``** — props `DriverInstanceId`, `Enabled`. Opens hub on init, calls `JoinDriver`, registers `On("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 `` — 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 ``; 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 `` 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//drivers/new`. Verify 9 driver-type cards render. - Click "ModbusTcp". Verify the typed form opens on `/clusters//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//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 2–4 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` + `` 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 + `` (read-only first). 7. Land `` + `IDriverProbe` impls + AdminOperationsActor handler. 8. Land Reconnect/Restart on the status panel with `DriverOperator` policy. 9. Land 9 static address builders inside ``. ## 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.