diff --git a/docs/plans/2026-05-28-adminui-driver-pages-design.md b/docs/plans/2026-05-28-adminui-driver-pages-design.md new file mode 100644 index 00000000..c51c9100 --- /dev/null +++ b/docs/plans/2026-05-28-adminui-driver-pages-design.md @@ -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./ (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 (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` + `` 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.