# 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 "Modbus". Verify the typed form opens on `/clusters/<id>/drivers/new/modbus`. (The driver-type string is "Modbus" — canonicalised from the old "ModbusTcp" in Phase 4b, commit 08a65513.)

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.
   > **Shipped (TCP-only, 2026-05-28).** Real protocol handshakes (Modbus FC03, OPC UA GetEndpoints,
   > S7 COTP+setup-communication, CIP ForwardOpen, Galaxy gRPC ping) shipped 2026-06-16 via Phase 5
   > of the still-pending backlog (`docs/plans/2026-06-16-stillpending-phase-5-probes-design.md`).
   > TwinCAT ADS and FOCAS FWLIB handshakes degrade to TCP-reachability-only on hosts where the native
   > runtime is absent. See `docs/drivers/TestConnectProbes.md` for the full contract + degrade semantics.
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.
