494da22cd1
- 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.
309 lines
20 KiB
Markdown
309 lines
20 KiB
Markdown
# 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 (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 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` + `<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.
|