10 Commits

Author SHA1 Message Date
Joseph Doherty 64e4726fff docs(plans): mark all 48 driver-pages tasks complete in persistence file
Records final commit hashes + notes per task. Persistence file mirrors
the 43-commit branch state so future sessions can resume from the
correct checkpoint via /superpowers-extended-cc:executing-plans.
2026-05-28 11:32:45 -04:00
Joseph Doherty 494da22cd1 test(adminui): E2E scaffolding for Test Connect + Reconnect + Status hub
- 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.
2026-05-28 11:31:12 -04:00
Joseph Doherty 063005fefa feat(adminui): DriverTagPicker modal + 9 static address builders
- DriverTagPicker shell: modal chrome + per-driver picker body
  rendered as ChildContent.
- 9 picker bodies (Modbus/AbCip/AbLegacy/S7/TwinCat/FOCAS/
  OpcUaClient/Galaxy/Historian.Wonderware). 5 have computed
  builder logic + unit tests; 4 are free-text passthroughs
  (live browse for OPC UA + Galaxy is a documented follow-up).
- Each typed driver page gets a "Pick address" button that opens
  the modal with the matching body. Picked address surfaces in
  the modal footer for manual copy — no JS interop in v1.
2026-05-28 11:21:33 -04:00
Joseph Doherty ffcc8d1065 feat(adminui): Reconnect/Restart on DriverStatusPanel (DriverOperator-gated)
- RestartDriver / ReconnectDriver messages + AdminOperationsActor
  handlers (broadcast via driver-control DPS topic; audited via
  ConfigEdits).
- DriverHostActor subscribes to driver-control; locates the
  matching child DriverInstanceActor and stops+respawns it
  (Restart) or sends it a ForceReconnect internal message
  (Reconnect — re-enters Reconnecting state without full stop).
  DriverInstanceSpec constructor call uses named args to handle
  the full 6-parameter signature.
- New DriverOperator authorization policy mapped to DriverOperator
  or FleetAdmin role; documented in docs/security.md. Map LDAP
  group via GroupToRole (e.g. "ot-driver-operator": "DriverOperator").
- DriverStatusPanel renders Reconnect + Restart buttons when the
  user holds the DriverOperator policy (hidden otherwise). Restart
  requires an in-page Razor confirm block (no JS confirm, keeps
  SignalR event loop unblocked). Both buttons show a spinner and
  are disabled during in-flight; result chip auto-clears after 8s.
  Username sourced from AuthenticationStateProvider.

Reconnect resolves to "ForceReconnect" (re-enter Reconnecting,
not full stop+respawn) — transport drops and retries while actor
and in-memory state are preserved. All DriverInstanceActor states
handle ForceReconnect safely (no-op when already in transition).
2026-05-28 11:14:04 -04:00
Joseph Doherty 4b374fd177 feat(adminui): Test Connect button on every typed driver page
- AdminProbeService routes TestDriverConnect through
  IAdminOperationsClient with a 65s outer guard (actor side already
  clamps to [1,60]).
- Added generic AskAsync<T> to IAdminOperationsClient interface and
  AdminOperationsClient impl, delegating straight to the Akka proxy.
- DriverTestConnectButton renders the button + inline result chip,
  auto-clears after 30s, disables during in-flight.
- Wired into all 9 typed driver pages directly under the
  identity section. Sources timeout from the form's
  ProbeTimeoutSeconds; sources config JSON from the form's
  current Options (operator can test BEFORE saving).
2026-05-28 11:02:49 -04:00
Joseph Doherty 54f0dbddb9 fix(drivers): align probe DriverType strings with AdminUI keys
ModbusDriverProbe.DriverType was "Modbus" but the AdminUI's
ModbusDriverPage persists DriverInstance.DriverType = "ModbusTcp".
GalaxyDriverProbe used the runtime DriverTypeName constant
("GalaxyMxGateway") but the AdminUI saves "Galaxy". The probe DI
lookup is case-insensitive but not name-insensitive, so Test
Connect would fail to find a probe for these two drivers.
2026-05-28 10:55:15 -04:00
Joseph Doherty c19d124e89 feat(drivers): TCP-connect IDriverProbe for all 9 driver types
Cheap-and-fast probe: open TCP socket to the configured endpoint,
close immediately. Surfaces SocketError on failure, latency on
success, "timed out" on caller cancel. Sufficient for the AdminUI
Test Connect "can we reach the host?" question. Richer protocol-
level probes (OPC UA session open, FOCAS handshake, gRPC ping)
are a documented follow-up. Each probe registered as
AddSingleton<IDriverProbe, X> in DriverFactoryBootstrap so they
flow through DI into AdminOperationsActor.

Historian.Wonderware returns a clean "TCP probe not applicable"
result because it communicates over a Windows named pipe, not TCP.
Also adds OpcUaClient + Historian.Wonderware.Client project
references to Host.csproj (both were missing from the driver
ItemGroup).
2026-05-28 10:53:42 -04:00
Joseph Doherty f3f328c25c feat(adminops): IDriverProbe + TestDriverConnect actor handler
- IDriverProbe abstraction in Core.Abstractions; one impl per driver
  type, resolved by DriverType string. Phase 7.3 + 7.4 add concrete
  probes for the 9 supported driver types.
- TestDriverConnect / TestDriverConnectResult messages.
- AdminOperationsActor.HandleTestDriverConnectAsync looks up the probe
  by DriverType, runs it with a [1,60]s clamped timeout, and returns
  success/latency or failure/message. Probes that throw or time out
  surface as soft failures.
2026-05-28 10:44:00 -04:00
Joseph Doherty 4584612a1a feat(adminui): DriverStatusPanel + wire into 9 typed pages
Live panel subscribed to the /hubs/driverstatus SignalR feed —
renders state chip, last-success age, 5-min error count, last
error message. Auto-reconnect; dimmed when no push arrives for 30s.
Hidden for new instances (nothing deployed yet); shown read-only
on every edit-mode page. Reconnect/Restart buttons land in Phase 8.
2026-05-28 10:29:43 -04:00
Joseph Doherty 4203b84d51 feat(runtime): publish DriverHealthChanged via DriverInstanceActor
- IDriverHealthPublisher in Core.Abstractions + NullDriverHealthPublisher
  no-op for tests/dev-stub paths.
- AkkaDriverHealthPublisher in Runtime forwards to the cluster-wide
  `driver-health` DPS topic.
- DriverInstanceActor instrumented to publish snapshots on every
  observable state change + a periodic 30s heartbeat so the AdminUI
  snapshot store warms up for newly-joined SignalR clients.
- Sliding 5-minute Faulted-count tracked per actor via Queue<DateTime>.
- DriverHostActor.SpawnChild threads clusterId (_localNode.Value) and
  the health publisher down to every DriverInstanceActor child.
- ServiceCollectionExtensions.AddOtOpcUaRuntime registers
  AkkaDriverHealthPublisher as IDriverHealthPublisher singleton.
2026-05-28 10:22:44 -04:00
69 changed files with 3171 additions and 69 deletions
+2 -4
View File
@@ -1,9 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Akka" Version="1.5.62" />
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
@@ -73,6 +71,7 @@
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
@@ -99,5 +98,4 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
</ItemGroup>
</Project>
</Project>
@@ -238,12 +238,48 @@ The picker slot is wired so swapping a static builder for a live browser later i
- `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)
### 8.3 Manual smoke (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.
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 24 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
@@ -2,40 +2,43 @@
"planPath": "docs/plans/2026-05-28-adminui-driver-pages-plan.md",
"designPath": "docs/plans/2026-05-28-adminui-driver-pages-design.md",
"tasks": [
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "pending"},
{"id": "0.1", "subject": "Create AdminUI test project + slnx entry + placeholder test", "status": "completed", "commit": "dc12c37"},
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "pending", "blockedBy": ["0.1"]},
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "pending", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"]},
{"id": "1.1", "subject": "Driver.Modbus.Contracts — extract ModbusDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5058a56", "notes": "Has 1 ProjectReference to Modbus.Addressing (sibling zero-dep enum project) — design intent preserved."},
{"id": "1.2", "subject": "Driver.AbCip.Contracts — extract AbCipDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "b474d63", "notes": "AbCipDataType enum moved with Options; extensions split into runtime."},
{"id": "1.3", "subject": "Driver.AbLegacy.Contracts — extract AbLegacyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "4902295", "notes": "AbLegacyDataType + AbLegacyPlcFamilyProfile also moved; extensions split."},
{"id": "1.4", "subject": "Driver.S7.Contracts — extract S7DriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "9f62f2c", "notes": "Parallel S7CpuType enum (7 values) + S7CpuTypeMap in runtime; S7.Cli + 2 tests fixed for type change."},
{"id": "1.5", "subject": "Driver.TwinCAT.Contracts — extract TwinCATDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "a88721c", "notes": "TwinCATDataType enum moved; extensions split."},
{"id": "1.6", "subject": "Driver.FOCAS.Contracts — extract FocasDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "d892ab9", "notes": "FocasCncSeries + FocasDataType enums moved; extensions split."},
{"id": "1.7", "subject": "Driver.OpcUaClient.Contracts — extract OpcUaClientDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5f0e048", "notes": "All 4 enums self-contained in options file; no NuGet types leaked."},
{"id": "1.8", "subject": "Driver.Galaxy.Contracts — extract GalaxyDriverOptions", "status": "completed", "blockedBy": ["0.1"], "commit": "5ffbc42", "notes": "Moved from Config/ subdir to contracts root; namespace preserved."},
{"id": "1.9", "subject": "Driver.Historian.Wonderware.Client.Contracts — extract options", "status": "completed", "blockedBy": ["0.1"], "commit": "8c0a320", "notes": "Pure record, primitives only."},
{"id": "1.10", "subject": "Add ProbeTimeoutSeconds to all 9 Options classes + slnx validation", "status": "completed", "blockedBy": ["1.1","1.2","1.3","1.4","1.5","1.6","1.7","1.8","1.9"], "commit": "f2f6eeb"},
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "pending", "blockedBy": ["0.1"]},
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "pending", "blockedBy": ["0.1"]},
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "pending", "blockedBy": ["0.1"]},
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "pending", "blockedBy": ["2.1","2.2","2.3"]},
{"id": "2.1", "subject": "DriverFormShell.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "85af126"},
{"id": "2.2", "subject": "DriverIdentitySection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "1ff3875", "notes": "Bonus ValidationMessage tags added."},
{"id": "2.3", "subject": "DriverResilienceSection.razor", "status": "completed", "blockedBy": ["0.1"], "commit": "a008530"},
{"id": "2.4", "subject": "Wire shared sections into existing DriverEdit.razor", "status": "completed", "blockedBy": ["2.1","2.2","2.3"], "commit": "a28f4cd", "notes": "Net -74 lines; zero functional regression."},
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "pending", "blockedBy": ["2.4"]},
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "pending", "blockedBy": ["2.4"]},
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "pending", "blockedBy": ["3.1"]},
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "pending", "blockedBy": ["3.2","3.3"]},
{"id": "3.1", "subject": "DriverTypePicker.razor (route: /drivers/new)", "status": "completed", "blockedBy": ["2.4"], "commit": "c0ce5d0"},
{"id": "3.2", "subject": "DriverEditRouter.razor with DynamicComponent dispatch","status": "completed", "blockedBy": ["2.4"], "commit": "55e8bf7"},
{"id": "3.3", "subject": "Hand /drivers/new from DriverEdit to DriverTypePicker","status": "completed", "blockedBy": ["3.1"], "commit": "27b3a01", "notes": "Bundled with 3.4 — single commit removed both @page directives."},
{"id": "3.4", "subject": "Hand /drivers/{id} from DriverEdit to DriverEditRouter (fallback to DriverEdit)", "status": "completed", "blockedBy": ["3.2","3.3"], "commit": "27b3a01"},
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "pending", "blockedBy": ["1.10","3.4"]},
{"id": "4.0", "subject": "AdminUI csproj references all 9 Driver.*.Contracts", "status": "completed", "blockedBy": ["1.10","3.4"], "commit": "7014c93", "notes": "Inserted as a precondition for parallel 4.1-4.9 implementation."},
{"id": "4.1", "subject": "ModbusDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a3073d1"},
{"id": "4.2", "subject": "AbCipDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dc21cba"},
{"id": "4.3", "subject": "AbLegacyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "059a621"},
{"id": "4.4", "subject": "S7DriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "5cad9b2"},
{"id": "4.5", "subject": "TwinCatDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "dfbf679"},
{"id": "4.6", "subject": "FocasDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "8149739"},
{"id": "4.7", "subject": "OpcUaClientDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "efcc231"},
{"id": "4.8", "subject": "GalaxyDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "a243cfd"},
{"id": "4.9", "subject": "HistorianWonderwareDriverPage.razor + serialization test", "status": "completed", "blockedBy": ["4.0"], "commit": "2c16062"},
{"id": "4.10","subject": "Wire all 9 typed pages into DriverEditRouter._componentMap", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "5f8fa70"},
{"id": "4.11","subject": "Fixup: S7 Tags data-loss + missing FormModel tests (post-review)", "status": "completed", "blockedBy": ["4.10"], "commit": "c4086c2"},
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "pending", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"]},
{"id": "5.1", "subject": "Delete DriverEdit.razor + remove fallback in DriverEditRouter", "status": "completed", "blockedBy": ["4.1","4.2","4.3","4.4","4.5","4.6","4.7","4.8","4.9"], "commit": "a971db3"},
{"id": "6.1", "subject": "DriverHealthChanged DPS message contract", "status": "pending", "blockedBy": ["5.1"]},
{"id": "6.2", "subject": "Publish DriverHealthChanged from each driver actor (IDriverHealthPublisher)", "status": "pending", "blockedBy": ["6.1"]},
+2 -1
View File
@@ -251,7 +251,8 @@ The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.
|---|---|
| `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. |
| `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. |
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. |
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. Also satisfies the `DriverOperator` authorization policy. |
| `DriverOperator` | May issue **Reconnect** and **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel`. Gated by the `DriverOperator` named policy in `AddAuthorization` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). Map an LDAP group via `GroupToRole`, e.g. `"ot-driver-operator": "DriverOperator"`. |
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) and Razor pages gate inline with the role names, e.g. `@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]` on `Deployments.razor`. Nav-menu sections hide via `<AuthorizeView>`.
@@ -14,4 +14,14 @@ public interface IAdminOperationsClient
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
/// <summary>
/// Generic Ask: forwards <paramref name="message"/> to the AdminOperationsActor
/// cluster-singleton proxy and awaits a reply of type <typeparamref name="T"/>.
/// The caller is responsible for applying any outer timeout via <paramref name="ct"/>.
/// </summary>
/// <typeparam name="T">Expected reply type.</typeparam>
/// <param name="message">The message to send.</param>
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
Task<T> AskAsync<T>(object message, CancellationToken ct);
}
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor: reconnect the driver actor's transport without
/// respawning the actor itself. Sends the actor back through its Reconnecting state —
/// fast, preserves in-memory state. The driver actor's supervisor performs the work.
/// </summary>
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
/// <param name="DriverInstanceId">The driver instance to reconnect.</param>
/// <param name="ActorByUserName">The authenticated admin user who triggered the reconnect.</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record ReconnectDriver(
string ClusterId,
string DriverInstanceId,
string ActorByUserName,
Guid CorrelationId);
/// <summary>Reply for <see cref="ReconnectDriver"/>.</summary>
/// <param name="Ok">True iff the operation was dispatched without error.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record ReconnectDriverResult(
bool Ok,
string? Message,
Guid CorrelationId);
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor: restart the driver actor for one instance.
/// A restart fully stops and respawns the actor — loses in-memory state, may briefly
/// interrupt active subscriptions. The driver actor's supervisor performs the work.
/// </summary>
/// <param name="ClusterId">Cluster scope identifier (for audit).</param>
/// <param name="DriverInstanceId">The driver instance to restart.</param>
/// <param name="ActorByUserName">The authenticated admin user who triggered the restart.</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record RestartDriver(
string ClusterId,
string DriverInstanceId,
string ActorByUserName,
Guid CorrelationId);
/// <summary>Reply for <see cref="RestartDriver"/>.</summary>
/// <param name="Ok">True iff the operation was dispatched without error.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record RestartDriverResult(
bool Ok,
string? Message,
Guid CorrelationId);
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor request: probe one driver type's connection using
/// the supplied JSON config. Routed through <c>IAdminOperationsClient</c>; reply is
/// <see cref="TestDriverConnectResult"/>.
/// </summary>
/// <param name="DriverType">Must match an installed <c>IDriverProbe.DriverType</c>.</param>
/// <param name="ConfigJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="TimeoutSeconds">Per-probe timeout; server clamps to [1, 60].</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record TestDriverConnect(
string DriverType,
string ConfigJson,
int TimeoutSeconds,
Guid CorrelationId);
/// <summary>Reply for <see cref="TestDriverConnect"/>.</summary>
/// <param name="Ok">True iff the probe succeeded.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="LatencyMs">Round-trip latency in milliseconds; null on failure or timeout.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record TestDriverConnectResult(
bool Ok,
string? Message,
double? LatencyMs,
Guid CorrelationId);
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Sink for driver-health state-change notifications. The runtime DI wires the
/// Akka-DistributedPubSub-backed implementation; tests and dev-stub paths use
/// <see cref="NullDriverHealthPublisher"/> to opt out without changing call sites.
/// </summary>
public interface IDriverHealthPublisher
{
/// <summary>
/// Publishes a health snapshot for one driver instance. Implementations must be
/// non-blocking and tolerant of being called from any thread.
/// </summary>
void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min);
}
/// <summary>
/// Drop-in no-op for tests and dev-stub paths. Production wires the Akka-backed
/// implementation in the Runtime project.
/// </summary>
public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
{
/// <summary>Singleton instance.</summary>
public static readonly NullDriverHealthPublisher Instance = new();
private NullDriverHealthPublisher() { }
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min)
{ /* no-op */ }
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Test-connect probe for one driver type. Implementations deserialize a driver-config
/// JSON, attempt a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the
/// driver's native protocol supports), and report success/failure with latency. Probes
/// MUST NOT mutate any persistent state; the AdminUI invokes them against transient
/// config from the typed form, NOT against the persisted DriverInstance row.
/// </summary>
public interface IDriverProbe
{
/// <summary>DriverInstance.DriverType string this probe handles. Used for DI lookup.</summary>
string DriverType { get; }
/// <summary>
/// Run the probe with the supplied config + timeout. Honour <paramref name="ct"/> for
/// timeout cancellation. Never throw on connection failure; instead return a result
/// with <c>Ok = false</c> + a message.
/// </summary>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
/// <summary>Outcome of a single <see cref="IDriverProbe.ProbeAsync"/> call.</summary>
/// <param name="Ok">True iff the probe reached its target and the handshake succeeded.</param>
/// <param name="Message">Human-readable status; null on success.</param>
/// <param name="Latency">Wall-clock duration of the successful probe; null on failure.</param>
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any CIP bytes —
/// a richer EIP session-open probe is a documented follow-up.
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "AbCip";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
AbCipDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<AbCipDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(AbCipDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Gateway, parsed.Port);
}
}
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="AbLegacyDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's gateway host + EtherNet/IP port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any PCCC bytes —
/// a richer EIP session-open probe is a documented follow-up.
/// </summary>
public sealed class AbLegacyDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "AbLegacy";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
AbLegacyDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<AbLegacyDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Gateway, parsed.Port);
}
}
@@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's FOCAS Ethernet address + port and closes
/// immediately. Surfaces a green tick + latency on success; red chip + SocketError on
/// failure; "timed out" on the caller's cancellation. Does NOT exchange any FOCAS/2 bytes —
/// a richer FOCAS handshake (cnc_allclibhndl3) probe is a documented follow-up.
/// </summary>
public sealed class FocasDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "FOCAS";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
FocasDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<FocasDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(FocasDriverOptions opts)
{
// Parse the first device's focas:// address to extract host + port.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = FocasHostAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
return (parsed.Host, parsed.Port);
}
}
@@ -0,0 +1,86 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="GalaxyDriverOptions"/>-shaped driver config.
/// Parses the <c>Gateway.Endpoint</c> gRPC endpoint (e.g. <c>http://host:5001</c> or
/// <c>host:5001</c>), opens a socket and closes immediately. Surfaces a green tick +
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
/// cancellation. Does NOT exchange any gRPC frames — a richer gRPC ping probe is a
/// documented follow-up.
/// </summary>
public sealed class GalaxyDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
// Matches DriverInstance.DriverType strings set by the AdminUI's GalaxyDriverPage.
public string DriverType => "Galaxy";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
GalaxyDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<GalaxyDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(GalaxyDriverOptions opts)
{
var endpoint = opts.Gateway.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint)) return (string.Empty, 0);
// Try absolute URI first (e.g. "http://hostname:5001" or "https://hostname:5001").
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
var host = uri.Host;
// Uri.Port is -1 when not specified; default mxaccessgw port is 5001.
var port = uri.Port > 0 ? uri.Port : 5001;
return (host, port);
}
// Fallback: treat as "host:port" (no scheme).
var colonIdx = endpoint.LastIndexOf(':');
if (colonIdx > 0 && int.TryParse(endpoint[(colonIdx + 1)..], out var rawPort) && rawPort > 0)
return (endpoint[..colonIdx], rawPort);
// No port found — return the whole string as host with default port.
return (endpoint, 5001);
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// <summary>
/// Driver probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver config.
/// The Wonderware Historian client communicates over a Windows named pipe (not a TCP socket),
/// so a cheap TCP-connect probe is not applicable for this transport. This probe always
/// returns a well-formed "not applicable" result so the AdminUI can display a meaningful
/// message instead of a red error. A full named-pipe connect + Hello-frame probe is a
/// documented follow-up.
/// </summary>
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "Historian.Wonderware";
/// <inheritdoc />
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
// Validate the config JSON can at least be parsed — surface bad JSON immediately.
WonderwareHistorianClientOptions? opts;
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
catch (Exception ex)
{
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
}
if (opts is null)
return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null));
// The Wonderware Historian sidecar communicates over a Windows named pipe; there is no
// TCP endpoint to connect to. A full pipe connect + Hello-frame probe is a follow-up.
return Task.FromResult(new DriverProbeResult(
false,
"TCP probe not applicable for this transport — the Historian sidecar uses a named pipe. " +
"A full named-pipe probe is a documented follow-up.",
null));
}
}
@@ -0,0 +1,63 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
/// Opens a socket to the configured endpoint and closes immediately. Surfaces a green
/// tick + latency on success; red chip + SocketError on failure; "timed out" on the
/// caller's cancellation. Does NOT exchange any protocol bytes — richer per-driver
/// handshakes are a documented follow-up.
/// </summary>
public sealed class ModbusDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "ModbusTcp";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
ModbusDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -0,0 +1,78 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="OpcUaClientDriverOptions"/>-shaped driver config.
/// Parses the first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or
/// the convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> fallback), opens a
/// socket to the OPC UA server host + port and closes immediately. Surfaces a green tick +
/// latency on success; red chip + SocketError on failure; "timed out" on the caller's
/// cancellation. Does NOT open an OPC UA session — a richer session-open probe is a
/// documented follow-up.
/// </summary>
public sealed class OpcUaClientDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "OpcUaClient";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
OpcUaClientDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(OpcUaClientDriverOptions opts)
{
// EndpointUrls wins over the convenience EndpointUrl when both are set.
var endpointUrl = opts.EndpointUrls.FirstOrDefault()
?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl);
if (endpointUrl is null) return (string.Empty, 0);
// Parse as a URI — opc.tcp://host:port is a valid URI.
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
return (string.Empty, 0);
var host = uri.Host;
var port = uri.IsDefaultPort ? 4840 : uri.Port;
return (host, port);
}
}
@@ -0,0 +1,63 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="S7DriverOptions"/>-shaped driver config.
/// Opens a socket to the configured host + ISO-on-TCP port 102 and closes immediately.
/// Surfaces a green tick + latency on success; red chip + SocketError on failure; "timed
/// out" on the caller's cancellation. Does NOT exchange any S7comm bytes — a richer
/// ISO-on-TCP connection probe is a documented follow-up.
/// </summary>
public sealed class S7DriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "S7";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
S7DriverOptions? opts;
try { opts = JsonSerializer.Deserialize<S7DriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -0,0 +1,84 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Cheap TCP-connect probe for the <see cref="TwinCATDriverOptions"/>-shaped driver config.
/// Opens a socket to the first device's AMS router host (first four octets of the AMS Net ID)
/// on the AMS port from the address and closes immediately. Surfaces a green tick + latency
/// on success; red chip + SocketError on failure; "timed out" on the caller's cancellation.
/// Does NOT exchange any ADS bytes — a richer ADS-state probe is a documented follow-up.
/// </summary>
/// <remarks>
/// AMS Net ID format is six dot-separated octets (e.g. <c>192.168.1.10.1.1</c>); the first
/// four are typically the host IPv4 address by Beckhoff convention, but the AMS router
/// resolves the real IP route server-side. The probe uses the first-four-octet heuristic
/// which is reliable for the overwhelming majority of production deployments.
/// </remarks>
public sealed class TwinCATDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
/// <inheritdoc />
public string DriverType => "TwinCAT";
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
TwinCATDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<TwinCATDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var sw = Stopwatch.StartNew();
try
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, ct);
sw.Stop();
return new(true, null, sw.Elapsed);
}
catch (SocketException ex)
{
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
}
catch (OperationCanceledException)
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (Exception ex)
{
return new(false, ex.Message, null);
}
}
private static (string host, int port) ExtractTarget(TwinCATDriverOptions opts)
{
// Parse the first device's ads:// address. AMS Net ID is six-octet; by Beckhoff
// convention the first four octets are the host IPv4. Extract those as the TCP target.
var firstDevice = opts.Devices.FirstOrDefault();
if (firstDevice is null) return (string.Empty, 0);
var parsed = TwinCATAmsAddress.TryParse(firstDevice.HostAddress);
if (parsed is null) return (string.Empty, 0);
// NetId = "a.b.c.d.e.f" — take the first 4 octets as the host IP.
var parts = parsed.NetId.Split('.');
if (parts.Length < 4) return (string.Empty, 0);
var hostIp = string.Join('.', parts[0], parts[1], parts[2], parts[3]);
return (hostIp, parsed.Port);
}
}
@@ -36,4 +36,12 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
linked.CancelAfter(AskTimeout);
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
}
/// <summary>
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
/// Uses the caller-supplied <paramref name="ct"/> for cancellation; does not impose an
/// additional internal timeout beyond what the proxy itself enforces.
/// </summary>
public Task<T> AskAsync<T>(object message, CancellationToken ct)
=> _proxy.Ask<T>(message, cancellationToken: ct);
}
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// Thin AdminUI-side wrapper for the Test Connect operation. Dispatches a
/// <see cref="TestDriverConnect"/> through <c>IAdminOperationsClient</c>, applies a
/// 65-second outer wall (the actor itself clamps to [1,60]s; this guards against the
/// Ask never replying), and surfaces a friendly result for the Razor button to render.
/// </summary>
public sealed class AdminProbeService
{
private readonly IAdminOperationsClient _client;
/// <summary>Initializes a new instance of the <see cref="AdminProbeService"/>.</summary>
/// <param name="client">The admin operations client used to dispatch probe requests.</param>
public AdminProbeService(IAdminOperationsClient client) => _client = client;
/// <summary>
/// Dispatches a Test Connect probe for the supplied driver type and config JSON,
/// waiting up to 65 seconds for a reply before surfacing a timeout failure.
/// </summary>
/// <param name="driverType">Driver type key (must match an installed <c>IDriverProbe.DriverType</c>).</param>
/// <param name="configJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="timeoutSeconds">Per-probe timeout; actor clamps to [1, 60].</param>
/// <param name="ct">Optional cancellation token from the caller.</param>
public async Task<TestDriverConnectResult> TestAsync(
string driverType,
string configJson,
int timeoutSeconds,
CancellationToken ct = default)
{
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId);
// 65s outer guard — the actor's CTS clamps to 60s; if the Ask never returns we still want
// a deterministic failure surface for the UI.
using var outerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
outerCts.CancelAfter(TimeSpan.FromSeconds(65));
try
{
return await _client.AskAsync<TestDriverConnectResult>(msg, outerCts.Token);
}
catch (OperationCanceledException)
{
return new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId);
}
catch (Exception ex)
{
return new TestDriverConnectResult(false, $"Probe dispatch failed: {ex.Message}", null, correlationId);
}
}
}
@@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions
{
services.AddScoped<IAdminOperationsClient, AdminOperationsClient>();
services.AddScoped<IFleetDiagnosticsClient, FleetDiagnosticsClient>();
services.AddScoped<AdminProbeService>();
return services;
}
}
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB CIP address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -171,6 +196,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbCipDeviceOptions> _devices = [];
private IReadOnlyList<AbCipTagDefinition> _tags = [];
@@ -299,6 +330,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@@ -37,6 +39,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB Legacy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -140,6 +165,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbLegacyDeviceOptions> _devices = [];
private IReadOnlyList<AbLegacyTagDefinition> _tags = [];
@@ -268,6 +299,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="FOCAS address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -229,6 +254,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -339,6 +370,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
@@ -201,6 +226,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -326,6 +357,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private static GalaxyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Historian.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Historian Wonderware address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<HistorianWonderwareAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Connection</div>
@@ -133,6 +158,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -255,6 +286,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Historian.ToRecord(), _jsonOpts);
private static WonderwareHistorianClientOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div>
@@ -286,6 +311,13 @@ else
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — rendered as read-only JSON.
private IReadOnlyList<ModbusTagDefinition> _tags = [];
private string _tagsJson = "[]";
@@ -410,6 +442,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="OPC UA address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
@@ -249,6 +274,12 @@ else
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Read-only JSON snippets for collections that have no list editor yet.
private string _endpointUrlsJson = "[]";
private string _unsMappingTableJson = "{}";
@@ -374,6 +405,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.OpcUa.ToRecord(), _jsonOpts);
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="S7 address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -165,6 +190,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -275,6 +306,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
@@ -3,7 +3,9 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@@ -36,6 +38,29 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="TwinCAT address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
@@ -171,6 +196,12 @@ else
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -281,6 +312,9 @@ else
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
@@ -0,0 +1,299 @@
@* Live driver-status panel — subscribes to /hubs/driverstatus and shows state chip,
last-success age, 5-min error count, and last error message.
Enabled=false renders a static "Disabled" notice and never opens the hub.
DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
@inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
<div class="panel-head d-flex align-items-center gap-2">
<span>Driver status</span>
@if (_snapshot is not null)
{
<span class="chip @ChipClass(_snapshot.State)">@_snapshot.State</span>
}
else if (!Enabled)
{
<span class="chip chip-idle">Disabled</span>
}
else if (_connecting)
{
<span class="chip chip-idle">Connecting&hellip;</span>
}
</div>
<div style="padding:1rem">
@if (!Enabled)
{
<p class="mb-0" style="color:var(--ink-soft)">Disabled &mdash; not deployed. Enable the driver and save to start receiving live status.</p>
}
else if (_error is not null)
{
<p class="mb-0" style="color:var(--bad)">SignalR error: @_error</p>
}
else if (_snapshot is null)
{
<p class="mb-0" style="color:var(--ink-faint)">Awaiting first snapshot&hellip;</p>
}
else
{
<div class="d-flex flex-wrap gap-3 align-items-baseline">
<span style="color:var(--ink-soft)">
Last success:
@if (_snapshot.LastSuccessfulReadUtc is { } t)
{
<strong>@HumanizeAge(t) ago</strong>
}
else
{
<strong>never</strong>
}
</span>
@if (_snapshot.ErrorCount5Min > 0)
{
<span class="chip chip-bad">@_snapshot.ErrorCount5Min error@(_snapshot.ErrorCount5Min == 1 ? "" : "s") / 5 min</span>
}
</div>
@if (_snapshot.LastError is { Length: > 0 } lastError)
{
<details class="mt-2" style="font-size:0.85rem">
<summary style="cursor:pointer; color:var(--ink-soft)">Last error</summary>
<pre class="mt-1 mb-0" style="white-space:pre-wrap; word-break:break-word; color:var(--bad); font-size:0.8rem">@lastError</pre>
</details>
}
}
@* --- Reconnect / Restart action buttons (DriverOperator-gated) --- *@
@if (_canOperate && Enabled)
{
<div class="d-flex gap-2 align-items-center mt-3">
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyReconnect"
@onclick="ReconnectAsync"
title="Re-establish driver transport without restarting the actor">
@if (_busyReconnect)
{
<span class="spinner-border spinner-border-sm me-1"></span>
<span>Reconnecting&hellip;</span>
}
else
{
<span>Reconnect</span>
}
</button>
<button type="button"
class="btn btn-sm btn-outline-danger"
disabled="@_busyRestart"
@onclick="() => _showRestartConfirm = true"
title="Stop and respawn the driver actor — briefly interrupts active subscriptions">
@if (_busyRestart)
{
<span class="spinner-border spinner-border-sm me-1"></span>
<span>Restarting&hellip;</span>
}
else
{
<span>Restart</span>
}
</button>
@if (_opResultMessage is not null)
{
<span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
}
</div>
@* Inline confirm dialog for Restart (no JS confirm — keeps SignalR event loop unblocked) *@
@if (_showRestartConfirm)
{
<div class="mt-2 p-2 border rounded" style="background:var(--surface-raised,#fff); border-color:var(--bad,#dc3545)!important; max-width:420px; font-size:0.9rem">
<p class="mb-2" style="color:var(--ink)">
Restart driver <code>@DriverInstanceId</code>?<br />
<span style="color:var(--ink-soft)">This briefly interrupts active subscriptions and clears in-memory state.</span>
</p>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-danger" @onclick="RestartConfirmedAsync">Confirm restart</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _showRestartConfirm = false">Cancel</button>
</div>
</div>
}
}
</div>
</section>
@code {
[Parameter, EditorRequired] public string DriverInstanceId { get; set; } = "";
/// <summary>Cluster identifier forwarded in Reconnect/Restart messages for audit.</summary>
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public bool Enabled { get; set; } = true;
private HubConnection? _hub;
private DriverHealthChanged? _snapshot;
private DateTime _lastUpdateUtc = DateTime.MinValue;
private bool _stale;
private bool _connecting;
private string? _error;
private System.Threading.Timer? _timer;
// Authorization
private bool _canOperate;
private string? _currentUserName;
// Action state
private bool _busyReconnect;
private bool _busyRestart;
private bool _showRestartConfirm;
private string? _opResultMessage;
private bool _opResultOk;
private System.Timers.Timer? _opResultClearTimer;
protected override async Task OnInitializedAsync()
{
// Check DriverOperator authorization so buttons only render for permitted users.
var auth = await AuthState.GetAuthenticationStateAsync();
_currentUserName = auth.User.Identity?.Name ?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown";
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, "DriverOperator");
_canOperate = authResult.Succeeded;
if (!Enabled)
return;
_connecting = true;
// Tick every 5 s to refresh the stale-dimming check and humanized ages.
_timer = new System.Threading.Timer(_ =>
{
_stale = _snapshot is not null &&
(DateTime.UtcNow - _lastUpdateUtc).TotalSeconds > 30;
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))
.WithAutomaticReconnect()
.Build();
_hub.On<DriverHealthChanged>("status", snap =>
{
_snapshot = snap;
_lastUpdateUtc = DateTime.UtcNow;
_stale = false;
InvokeAsync(StateHasChanged);
});
try
{
await _hub.StartAsync();
_connecting = false;
await _hub.InvokeAsync("JoinDriver", DriverInstanceId);
}
catch (Exception ex)
{
_connecting = false;
_error = ex.Message;
}
}
private async Task ReconnectAsync()
{
_busyReconnect = true;
_opResultMessage = null;
StateHasChanged();
try
{
var result = await AdminOps.AskAsync<ReconnectDriverResult>(
new ReconnectDriver(ClusterId, DriverInstanceId, _currentUserName ?? "unknown", Guid.NewGuid()),
new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)).Token);
ShowOpResult(result.Ok, result.Ok ? "Reconnect dispatched" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyReconnect = false;
StateHasChanged();
}
}
private async Task RestartConfirmedAsync()
{
_showRestartConfirm = false;
_busyRestart = true;
_opResultMessage = null;
StateHasChanged();
try
{
var result = await AdminOps.AskAsync<RestartDriverResult>(
new RestartDriver(ClusterId, DriverInstanceId, _currentUserName ?? "unknown", Guid.NewGuid()),
new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)).Token);
ShowOpResult(result.Ok, result.Ok ? "Restart dispatched" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyRestart = false;
StateHasChanged();
}
}
private void ShowOpResult(bool ok, string message)
{
_opResultOk = ok;
_opResultMessage = message;
// Auto-clear the result chip after 8 s.
_opResultClearTimer?.Dispose();
_opResultClearTimer = new System.Timers.Timer(8_000) { AutoReset = false };
_opResultClearTimer.Elapsed += async (_, _) =>
{
_opResultMessage = null;
await InvokeAsync(StateHasChanged);
};
_opResultClearTimer.Start();
}
public async ValueTask DisposeAsync()
{
_timer?.Dispose();
_opResultClearTimer?.Dispose();
if (_hub is not null)
await _hub.DisposeAsync();
}
// Map DriverState string → chip CSS class using the 4 defined theme variants.
private static string ChipClass(string? state) => state switch
{
"Healthy" => "chip-ok",
"Degraded" => "chip-warn",
"Connecting" => "chip-warn",
"Reconnecting" => "chip-warn",
"Faulted" => "chip-bad",
_ => "chip-idle", // Unknown, Initializing, null
};
private static string HumanizeAge(DateTime utc)
{
var age = DateTime.UtcNow - utc;
if (age.TotalSeconds < 2) return "just now";
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m {age.Seconds}s";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h {age.Minutes}m";
return $"{(int)age.TotalDays}d {age.Hours}h";
}
}
@@ -0,0 +1,52 @@
@* Shared modal shell for per-driver address pickers. Parent page toggles `Visible`;
the child fragment renders the per-driver picker body. The shell handles modal
chrome, the "Use this address" button, and dismisses on close. *@
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="OnCloseAsync"></button>
</div>
<div class="modal-body">
@ChildContent
</div>
<div class="modal-footer">
@if (!string.IsNullOrEmpty(CurrentAddress))
{
<code class="me-auto mono">@CurrentAddress</code>
}
<button type="button" class="btn btn-outline-secondary" @onclick="OnCloseAsync">Close</button>
<button type="button" class="btn btn-primary" disabled="@string.IsNullOrEmpty(CurrentAddress)"
@onclick="OnUseAsync">
Use this address
</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool Visible { get; set; }
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
[Parameter] public string Title { get; set; } = "Address builder";
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> OnPickAddress { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private async Task OnCloseAsync()
{
await VisibleChanged.InvokeAsync(false);
}
private async Task OnUseAsync()
{
await OnPickAddress.InvokeAsync(CurrentAddress);
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,82 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@inject AdminProbeService Probe
@implements IDisposable
<div class="d-inline-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Test Connect
</button>
@if (_result is not null)
{
@if (_result.Ok)
{
<span class="chip chip-ok" title="@($"Probe succeeded in {_result.LatencyMs:F0} ms")">
OK &middot; @_result.LatencyMs?.ToString("F0") ms
</span>
}
else
{
<span class="chip chip-bad" title="@_result.Message">
Failed &middot; @TruncatedMessage()
</span>
}
}
</div>
@code {
/// <summary>Driver type key — must match an installed <c>IDriverProbe.DriverType</c>.</summary>
[Parameter, EditorRequired] public string DriverType { get; set; } = "";
/// <summary>Callback that returns the current form config as JSON. Called at click time.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Per-probe timeout forwarded to the actor (actor clamps to [1, 60] s). Default 10 s.</summary>
[Parameter] public int TimeoutSeconds { get; set; } = 10;
private bool _busy;
private TestDriverConnectResult? _result;
private System.Timers.Timer? _clearTimer;
private async Task OnClickAsync()
{
_busy = true;
_result = null;
StateHasChanged();
try
{
var json = GetConfigJson() ?? "{}";
_result = await Probe.TestAsync(DriverType, json, TimeoutSeconds);
}
catch (Exception ex)
{
_result = new TestDriverConnectResult(false, ex.Message, null, Guid.Empty);
}
finally
{
_busy = false;
ScheduleClear();
StateHasChanged();
}
}
private void ScheduleClear()
{
_clearTimer?.Dispose();
_clearTimer = new System.Timers.Timer(30_000) { AutoReset = false };
_clearTimer.Elapsed += async (_, _) =>
{
_result = null;
await InvokeAsync(StateHasChanged);
};
_clearTimer.Start();
}
private string TruncatedMessage()
=> _result?.Message is null ? "" :
(_result.Message.Length > 60 ? _result.Message[..60] + "…" : _result.Message);
public void Dispose() => _clearTimer?.Dispose();
}
@@ -0,0 +1,57 @@
@* Static AB CIP address builder: tag name + optional element index → tag[idx] or tag *@
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Program:Main.MyTag"
@bind="_tagName" @bind:after="OnChangedAsync" />
<div class="form-text">Use dot notation for nested tags or UDT members.</div>
</div>
<div class="col-md-3">
<label class="form-label">Element index (optional)</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_elementIndex" @bind:after="OnChangedAsync" />
<div class="form-text">Leave 0 for non-array tags (no index appended).</div>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="abCipUseIdx"
@bind="_useIndex" @bind:after="OnChangedAsync" />
<label class="form-check-label" for="abCipUseIdx">Append index</label>
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private int _elementIndex = 0;
private bool _useIndex = false;
private string _built = "";
protected override void OnInitialized()
{
_built = Build();
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private string Build()
{
if (string.IsNullOrWhiteSpace(_tagName))
return "";
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts AB Legacy PLC file-type + file-number + element
/// into the canonical address string (e.g. N7:0).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class AbLegacyAddressBuilder
{
public static string Build(string fileType, int fileNumber, int element)
=> $"{fileType}{fileNumber}:{element}";
}
@@ -0,0 +1,58 @@
@* Static AB Legacy address builder: file type + file number + element → N7:0 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">File type</label>
<select class="form-select form-select-sm" @bind="_fileType" @bind:after="OnChangedAsync">
<option value="N">N — Integer</option>
<option value="B">B — Binary/Bit</option>
<option value="F">F — Float</option>
<option value="I">I — Input</option>
<option value="O">O — Output</option>
<option value="S">S — Status</option>
<option value="T">T — Timer</option>
<option value="C">C — Counter</option>
<option value="R">R — Control</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">File number</label>
<input type="number" class="form-control form-control-sm" min="0" max="999"
@bind="_fileNumber" @bind:after="OnChangedAsync" />
<div class="form-text">e.g. 7 for N7</div>
</div>
<div class="col-md-3">
<label class="form-label">Element</label>
<input type="number" class="form-control form-control-sm" min="0" max="9999"
@bind="_element" @bind:after="OnChangedAsync" />
<div class="form-text">e.g. 0 for N7:0</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _fileType = "N";
private int _fileNumber = 7;
private int _element = 0;
private string _built = "";
protected override void OnInitialized()
{
_built = AbLegacyAddressBuilder.Build(_fileType, _fileNumber, _element);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = AbLegacyAddressBuilder.Build(_fileType, _fileNumber, _element);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,45 @@
@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Parameter group</label>
<select class="form-select form-select-sm" @bind="_group" @bind:after="OnChangedAsync">
<option value="axis">axis</option>
<option value="spindle">spindle</option>
<option value="program">program</option>
<option value="status">status</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Parameter ID</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_parameterId" @bind:after="OnChangedAsync" />
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _group = "axis";
private int _parameterId = 0;
private string _built = "";
protected override void OnInitialized()
{
_built = FocasAddressBuilder.Build(_group, _parameterId);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = FocasAddressBuilder.Build(_group, _parameterId);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a FOCAS parameter group + parameter ID
/// into the canonical address string (e.g. axis:5).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class FocasAddressBuilder
{
public static string Build(string group, int parameterId)
=> $"{group}:{parameterId}";
}
@@ -0,0 +1,57 @@
@* Static Galaxy address builder: tag_name.AttributeName free text → verbatim.
Live Galaxy browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live Galaxy browse is deferred to a follow-up phase.
Enter the tag and attribute name manually below.
</div>
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DelmiaReceiver_001"
@bind="_tagName" @bind:after="OnChangedAsync" />
<div class="form-text">Globally unique system (tag) name.</div>
</div>
<div class="col-md-5">
<label class="form-label">Attribute name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="DownloadPath"
@bind="_attributeName" @bind:after="OnChangedAsync" />
<div class="form-text">MXAccess attribute name.</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private string _attributeName = "";
private string _built = "";
protected override void OnInitialized()
{
_built = Build();
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private string Build()
{
if (string.IsNullOrWhiteSpace(_tagName))
return "";
if (string.IsNullOrWhiteSpace(_attributeName))
return _tagName;
return $"{_tagName}.{_attributeName}";
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a Wonderware Historian tag name + retrieval mode
/// + interval into the canonical address query string (e.g. MyTag?mode=Cyclic&amp;interval=60).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class HistorianWonderwareAddressBuilder
{
public static string Build(string tagName, string mode, int interval)
=> $"{tagName}?mode={mode}&interval={interval}";
}
@@ -0,0 +1,52 @@
@* Static Wonderware Historian address builder: tag name + retrieval mode + interval
→ MyTag?mode=Cyclic&interval=60 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Tag name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="SysTimeHour"
@bind="_tagName" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">Retrieval mode</label>
<select class="form-select form-select-sm" @bind="_mode" @bind:after="OnChangedAsync">
<option value="Last">Last</option>
<option value="Cyclic">Cyclic</option>
<option value="Delta">Delta</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<input type="number" class="form-control form-control-sm" min="1"
@bind="_interval" @bind:after="OnChangedAsync" />
<div class="form-text">Polling/retrieval interval.</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _tagName = "";
private string _mode = "Cyclic";
private int _interval = 60;
private string _built = "";
protected override void OnInitialized()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = string.IsNullOrWhiteSpace(_tagName) ? "" : HistorianWonderwareAddressBuilder.Build(_tagName, _mode, _interval);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts Modbus register-type + offset + length into
/// the canonical address string used by the Modbus driver (e.g. 4x00001-1).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class ModbusAddressBuilder
{
public static string Build(string regType, int offset, int length)
{
var prefix = regType switch
{
"Coil" => "0x",
"DiscreteInput" => "1x",
"Input" => "3x",
"Holding" => "4x",
_ => "4x",
};
return $"{prefix}{offset:00000}-{length}";
}
}
@@ -0,0 +1,51 @@
@* Static Modbus address builder: register type + offset + length → 4x00001-4 *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Register type</label>
<select class="form-select form-select-sm" @bind="_regType" @bind:after="OnChangedAsync">
<option value="Coil">Coil (0x)</option>
<option value="DiscreteInput">DiscreteInput (1x)</option>
<option value="Input">Input (3x)</option>
<option value="Holding">Holding (4x)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Offset</label>
<input type="number" class="form-control form-control-sm" min="0" max="99999"
@bind="_offset" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<input type="number" class="form-control form-control-sm" min="1" max="125"
@bind="_length" @bind:after="OnChangedAsync" />
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _regType = "Holding";
private int _offset = 1;
private int _length = 1;
private string _built = "";
protected override void OnInitialized()
{
_built = ModbusAddressBuilder.Build(_regType, _offset, _length);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = ModbusAddressBuilder.Build(_regType, _offset, _length);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,43 @@
@* Static OPC UA Client address builder: NodeId free text → verbatim.
Live browse deferred to a follow-up phase. *@
<div class="alert alert-info py-2 px-3 mb-3 small">
<strong>Note:</strong> Live OPC UA node browse is deferred to a follow-up phase.
Enter the NodeId string manually below.
</div>
<div class="row g-3">
<div class="col-md-10">
<label class="form-label">NodeId</label>
<input type="text" class="form-control form-control-sm mono" placeholder="ns=2;s=Channel.Device.Tag"
@bind="_nodeId" @bind:after="OnChangedAsync" />
<div class="form-text">
OPC UA NodeId string, e.g. <code>ns=2;s=Channel.Device.Tag</code> or <code>i=1001</code>.
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _nodeId = "";
private string _built = "";
protected override void OnInitialized()
{
_built = _nodeId;
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = _nodeId;
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts S7 area + db-number + offset + data-type
/// into the canonical S7 address string (e.g. DB10.DBD20:REAL, M0.0:X).
/// Extracted so unit tests can call it without bUnit.
/// </summary>
public static class S7AddressBuilder
{
/// <param name="area">DB / M / I / Q</param>
/// <param name="dbNumber">Only relevant when area == "DB".</param>
/// <param name="offset">Byte offset (decimal).</param>
/// <param name="s7Type">X / B / W / D / REAL</param>
public static string Build(string area, int dbNumber, int offset, string s7Type)
{
if (area == "DB")
{
// e.g. DB10.DBD20:REAL / DB1.DBX0.0:X / DB5.DBW4:W
var qualifier = s7Type switch
{
"X" => $"DBX{offset}.0",
"B" => $"DBB{offset}",
"W" => $"DBW{offset}",
"D" => $"DBD{offset}",
"REAL" => $"DBD{offset}",
_ => $"DBD{offset}",
};
return $"DB{dbNumber}.{qualifier}:{s7Type}";
}
else
{
// e.g. M0.0:X / I4:B / Q2:W
var offsetStr = s7Type == "X" ? $"{offset}.0" : $"{offset}";
return $"{area}{offsetStr}:{s7Type}";
}
}
}
@@ -0,0 +1,65 @@
@* Static S7 address builder: area + db-number + offset + type → DB10.DBD20:REAL / M0.0:X *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-3">
<div class="col-md-2">
<label class="form-label">Area</label>
<select class="form-select form-select-sm" @bind="_area" @bind:after="OnChangedAsync">
<option value="DB">DB — Data Block</option>
<option value="M">M — Merker</option>
<option value="I">I — Input</option>
<option value="Q">Q — Output</option>
</select>
</div>
@if (_area == "DB")
{
<div class="col-md-2">
<label class="form-label">DB number</label>
<input type="number" class="form-control form-control-sm" min="1" max="65535"
@bind="_dbNumber" @bind:after="OnChangedAsync" />
</div>
}
<div class="col-md-2">
<label class="form-label">Offset (bytes)</label>
<input type="number" class="form-control form-control-sm" min="0" max="65535"
@bind="_offset" @bind:after="OnChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">S7 type</label>
<select class="form-select form-select-sm" @bind="_s7Type" @bind:after="OnChangedAsync">
<option value="X">X — Bit</option>
<option value="B">B — Byte</option>
<option value="W">W — Word</option>
<option value="D">D — DWord</option>
<option value="REAL">REAL — Float</option>
</select>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _area = "DB";
private int _dbNumber = 1;
private int _offset = 0;
private string _s7Type = "REAL";
private string _built = "";
protected override void OnInitialized()
{
_built = S7AddressBuilder.Build(_area, _dbNumber, _offset, _s7Type);
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = S7AddressBuilder.Build(_area, _dbNumber, _offset, _s7Type);
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -0,0 +1,37 @@
@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">ADS variable name</label>
<input type="text" class="form-control form-control-sm mono" placeholder="MAIN.fValue"
@bind="_varName" @bind:after="OnChangedAsync" />
<div class="form-text">
Full ADS symbol path, e.g. <code>MAIN.fValue</code> or <code>GVL.iCounter</code>.
</div>
</div>
</div>
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
private string _varName = "";
private string _built = "";
protected override void OnInitialized()
{
_built = _varName;
_ = CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
{
_built = _varName;
await CurrentAddressChanged.InvokeAsync(_built);
}
}
@@ -1,4 +1,5 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
@@ -7,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -19,28 +21,37 @@ public sealed class AdminOperationsActor : ReceiveActor
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly IActorRef _coordinator;
private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for the admin operations actor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
/// <returns>Props configured to create an AdminOperationsActor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator));
IActorRef coordinator,
IEnumerable<IDriverProbe> probes) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes));
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
public AdminOperationsActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator)
IActorRef coordinator,
IEnumerable<IDriverProbe> probes)
{
_dbFactory = dbFactory;
_coordinator = coordinator;
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
ReceiveAsync<StartDeployment>(HandleStartDeploymentAsync);
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync);
ReceiveAsync<RestartDriver>(HandleRestartDriverAsync);
ReceiveAsync<ReconnectDriver>(HandleReconnectDriverAsync);
}
private async Task HandleStartDeploymentAsync(StartDeployment msg)
@@ -112,4 +123,112 @@ public sealed class AdminOperationsActor : ReceiveActor
msg.CorrelationId));
}
}
private async Task HandleTestDriverConnectAsync(TestDriverConnect msg)
{
var replyTo = Sender;
if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
{
replyTo.Tell(new TestDriverConnectResult(
false,
$"No probe registered for driver type '{msg.DriverType}'.",
null,
msg.CorrelationId));
return;
}
var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
var timeout = TimeSpan.FromSeconds(clampedSec);
using var cts = new CancellationTokenSource(timeout);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = await probe.ProbeAsync(msg.ConfigJson, timeout, cts.Token);
sw.Stop();
replyTo.Tell(new TestDriverConnectResult(
result.Ok,
result.Message,
result.Ok ? sw.Elapsed.TotalMilliseconds : (double?)null,
msg.CorrelationId));
}
catch (OperationCanceledException)
{
replyTo.Tell(new TestDriverConnectResult(
false,
$"Probe timed out after {clampedSec}s.",
null,
msg.CorrelationId));
}
catch (Exception ex)
{
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
replyTo.Tell(new TestDriverConnectResult(
false,
ex.Message,
null,
msg.CorrelationId));
}
}
private async Task HandleRestartDriverAsync(RestartDriver msg)
{
var replyTo = Sender;
try
{
// Broadcast to every DriverHostActor on every node via the driver-control DPS topic.
// Only the host that owns the instance will act; others ignore it (id not found in _children).
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish("driver-control", msg));
await using var db = await _dbFactory.CreateDbContextAsync();
db.ConfigEdits.Add(new ConfigEdit
{
EntityType = "DriverInstance",
EntityId = Guid.TryParse(msg.DriverInstanceId, out var guid) ? guid : Guid.Empty,
FieldsJson = $"{{\"op\":\"restart\",\"driverInstanceId\":{System.Text.Json.JsonSerializer.Serialize(msg.DriverInstanceId)}}}",
EditedBy = msg.ActorByUserName,
SourceNode = Akka.Cluster.Cluster.Get(Context.System).SelfAddress.Host ?? "unknown",
});
await db.SaveChangesAsync();
_log.Info("AdminOps: RestartDriver dispatched for {DriverInstanceId} by {User}",
msg.DriverInstanceId, msg.ActorByUserName);
replyTo.Tell(new RestartDriverResult(true, null, msg.CorrelationId));
}
catch (Exception ex)
{
_log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId}", msg.DriverInstanceId);
replyTo.Tell(new RestartDriverResult(false, ex.Message, msg.CorrelationId));
}
}
private async Task HandleReconnectDriverAsync(ReconnectDriver msg)
{
var replyTo = Sender;
try
{
// Broadcast to every DriverHostActor; only the one owning the instance reacts.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish("driver-control", msg));
await using var db = await _dbFactory.CreateDbContextAsync();
db.ConfigEdits.Add(new ConfigEdit
{
EntityType = "DriverInstance",
EntityId = Guid.TryParse(msg.DriverInstanceId, out var guid) ? guid : Guid.Empty,
FieldsJson = $"{{\"op\":\"reconnect\",\"driverInstanceId\":{System.Text.Json.JsonSerializer.Serialize(msg.DriverInstanceId)}}}",
EditedBy = msg.ActorByUserName,
SourceNode = Akka.Cluster.Cluster.Get(Context.System).SelfAddress.Host ?? "unknown",
});
await db.SaveChangesAsync();
_log.Info("AdminOps: ReconnectDriver dispatched for {DriverInstanceId} by {User}",
msg.DriverInstanceId, msg.ActorByUserName);
replyTo.Tell(new ReconnectDriverResult(true, null, msg.CorrelationId));
}
catch (Exception ex)
{
_log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId}", msg.DriverInstanceId);
replyTo.Tell(new ReconnectDriverResult(false, ex.Message, msg.CorrelationId));
}
}
}
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
@@ -59,7 +60,8 @@ public static class ServiceCollectionExtensions
{
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
return AdminOperationsActor.Props(dbFactory, coordinator);
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
return AdminOperationsActor.Props(dbFactory, coordinator, probes);
},
singletonOptions);
@@ -21,6 +21,7 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -5,6 +5,17 @@ using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
// Probe type aliases — keep the using list concise.
using ModbusProbe = Driver.Modbus.ModbusDriverProbe;
using AbCipProbe = Driver.AbCip.AbCipDriverProbe;
using AbLegacyProbe = Driver.AbLegacy.AbLegacyDriverProbe;
using S7Probe = Driver.S7.S7DriverProbe;
using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using HistorianProbe = Driver.Historian.Wonderware.Client.WonderwareHistorianDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
/// extension into a single <see cref="DriverFactoryRegistry"/> singleton and binds the
@@ -34,6 +45,18 @@ public static class DriverFactoryBootstrap
});
services.AddSingleton<IDriverFactory>(sp =>
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
// One IDriverProbe per driver type — wired into AdminOperationsActor via DI enumeration.
services.AddSingleton<IDriverProbe, ModbusProbe>();
services.AddSingleton<IDriverProbe, AbCipProbe>();
services.AddSingleton<IDriverProbe, AbLegacyProbe>();
services.AddSingleton<IDriverProbe, S7Probe>();
services.AddSingleton<IDriverProbe, TwinCATProbe>();
services.AddSingleton<IDriverProbe, FocasProbe>();
services.AddSingleton<IDriverProbe, OpcUaProbe>();
services.AddSingleton<IDriverProbe, GalaxyProbe>();
services.AddSingleton<IDriverProbe, HistorianProbe>();
return services;
}
@@ -55,7 +55,9 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
</ItemGroup>
@@ -0,0 +1,37 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// Forwards <see cref="DriverHealth"/> transitions to the cluster-wide
/// <c>driver-health</c> DistributedPubSub topic. Consumed by the AdminUI
/// <c>DriverStatusSignalRBridge</c>.
/// </summary>
public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
{
/// <summary>The DistributedPubSub topic name for driver-health snapshots.</summary>
public const string TopicName = "driver-health";
private readonly ActorSystem _system;
/// <summary>Initializes a new instance of <see cref="AkkaDriverHealthPublisher"/>.</summary>
/// <param name="system">The Akka actor system used to resolve the DPS mediator.</param>
public AkkaDriverHealthPublisher(ActorSystem system) => _system = system;
/// <inheritdoc />
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
{
var msg = new DriverHealthChanged(
clusterId,
driverInstanceId,
health.State.ToString(),
health.LastSuccessfulRead,
health.LastError,
errorCount5Min,
DateTime.UtcNow);
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
}
}
@@ -4,6 +4,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -36,6 +37,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
public const string DeploymentsTopic = "deployments";
public const string DeploymentAcksTopic = "deployment-acks";
public const string DriverControlTopic = "driver-control";
public static readonly TimeSpan ReconnectInterval = TimeSpan.FromSeconds(30);
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
@@ -45,6 +47,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IReadOnlySet<string> _localRoles;
private readonly IActorRef? _dependencyMux;
private readonly IActorRef? _opcUaPublishActor;
private readonly IDriverHealthPublisher _healthPublisher;
private readonly ILoggingAdapter _log = Context.GetLogger();
private RevisionHash? _currentRevision;
@@ -71,6 +74,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="localRoles">Optional set of roles assigned to the local node.</param>
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>
/// so test harnesses and smoke fixtures don't need to wire it.</param>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -78,9 +83,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IDriverFactory? driverFactory = null,
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null) =>
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null) =>
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor));
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -90,6 +96,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="localRoles">Optional set of roles assigned to the local node.</param>
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -97,7 +104,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IDriverFactory? driverFactory = null,
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null)
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null)
{
_dbFactory = dbFactory;
_localNode = localNode;
@@ -106,6 +114,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
_dependencyMux = dependencyMux;
_opcUaPublishActor = opcUaPublishActor;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
Become(Steady);
@@ -116,6 +125,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
// Subscribe to deployments topic so the coordinator's broadcast lands here.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentsTopic, Self));
// Subscribe to driver-control topic so AdminUI Reconnect/Restart commands land here.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DriverControlTopic, Self));
Bootstrap();
}
@@ -180,6 +191,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DispatchDeployment>(HandleDispatchFromSteady);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
@@ -199,6 +212,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
});
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
@@ -218,6 +233,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
});
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<RetryConfigDbConnection>(_ => TryRecoverFromStale());
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
}
@@ -357,17 +374,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
IActorRef child;
var clusterId = _localNode.Value;
if (stub)
{
child = Context.ActorOf(
DriverInstanceActor.Props(new StubbedDriver(spec.DriverInstanceId, spec.DriverType),
reconnectInterval: null, startStubbed: true),
DriverInstanceActor.Props(
new StubbedDriver(spec.DriverInstanceId, spec.DriverType),
reconnectInterval: null,
startStubbed: true,
healthPublisher: _healthPublisher,
clusterId: clusterId),
ActorNameFor(spec.DriverInstanceId));
}
else
{
child = Context.ActorOf(
DriverInstanceActor.Props(driver!),
DriverInstanceActor.Props(
driver!,
healthPublisher: _healthPublisher,
clusterId: clusterId),
ActorNameFor(spec.DriverInstanceId));
child.Tell(new DriverInstanceActor.InitializeRequested(spec.DriverConfig));
}
@@ -429,6 +454,42 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private void HandleRestartDriver(RestartDriver msg)
{
// DPS broadcast — only act if this node hosts the requested instance.
if (!_children.TryGetValue(msg.DriverInstanceId, out var entry))
return;
_log.Info("DriverHost {Node}: restarting driver {Id} by request of {User}",
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
Context.Stop(entry.Actor);
_children.Remove(msg.DriverInstanceId);
// Respawn using the same spec that was applied during the last reconcile.
SpawnChild(new DriverInstanceSpec(
DriverInstanceRowId: Guid.Empty,
DriverInstanceId: msg.DriverInstanceId,
Name: msg.DriverInstanceId,
DriverType: entry.DriverType,
Enabled: true,
DriverConfig: entry.LastConfigJson));
}
private void HandleReconnectDriver(ReconnectDriver msg)
{
// DPS broadcast — only act if this node hosts the requested instance.
if (!_children.TryGetValue(msg.DriverInstanceId, out var entry))
return;
_log.Info("DriverHost {Node}: reconnecting driver {Id} by request of {User}",
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
// Tell the child to drop its transport and re-enter the Reconnecting state.
entry.Actor.Tell(new DriverInstanceActor.ForceReconnect());
}
private void TryRecoverFromStale()
{
try
@@ -7,6 +7,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
// private timer key type — file-scoped so the name stays unique per-file
file sealed class HealthPollTick
{
public static readonly HealthPollTick Instance = new();
private HealthPollTick() { }
}
/// <summary>
/// Akka wrapper for a single <see cref="IDriver"/> instance. States:
///
@@ -37,6 +44,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
public sealed record SubscriptionEstablished(string DiagnosticId, int ReferenceCount);
public sealed record SubscriptionFailed(string Reason);
public sealed record Unsubscribe;
/// <summary>
/// Sent by <see cref="DriverHostActor"/> when the AdminUI issues a Reconnect operation.
/// Pushes the actor out of <c>Connected</c> into <c>Reconnecting</c> so the transport is
/// re-established without fully stopping and respawning the actor. Safe to send in any
/// state — a no-op when already Reconnecting or Connecting.
/// </summary>
public sealed record ForceReconnect;
/// <summary>Published to the actor's parent whenever the subscribed IDriver fires
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.</summary>
public sealed record AttributeValuePublished(string FullReference, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
@@ -47,12 +61,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private RetryConnect() { }
}
/// <summary>Interval between periodic health-poll heartbeats sent to the snapshot store.</summary>
public static readonly TimeSpan HealthPollInterval = TimeSpan.FromSeconds(30);
private readonly IDriver _driver;
private readonly string _driverInstanceId;
private readonly string _clusterId;
private readonly IDriverHealthPublisher _healthPublisher;
private readonly TimeSpan _reconnectInterval;
private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson;
/// <summary>Timestamps of recent Faulted-state transitions; used to compute the 5-minute error count.</summary>
private readonly Queue<DateTime> _faultTimestamps = new();
private readonly object _faultLock = new();
/// <summary>Active subscription handle (null when not subscribed). Lifetime is one-per-actor —
/// re-subscribe across reconnects is the consumer's responsibility today (subscribe-once
/// semantics keep the actor simple; mux-driven re-subscribe is tracked as F8b/#113).</summary>
@@ -70,8 +93,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="driver">The driver instance to wrap.</param>
/// <param name="reconnectInterval">Optional interval for reconnection attempts; defaults to 10 seconds.</param>
/// <param name="startStubbed">If true, the actor starts in stub mode for testing or unavailable platforms.</param>
public static Props Props(IDriver driver, TimeSpan? reconnectInterval = null, bool startStubbed = false) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(driver, reconnectInterval ?? DefaultReconnectInterval, startStubbed));
/// <param name="healthPublisher">Optional health publisher; defaults to <see cref="NullDriverHealthPublisher"/> so tests and
/// stub paths don't need to provide one.</param>
/// <param name="clusterId">Optional cluster identifier forwarded in <see cref="DriverHealthChanged"/> messages;
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
public static Props Props(
IDriver driver,
TimeSpan? reconnectInterval = null,
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
startStubbed,
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty));
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -101,10 +138,19 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="driver">The driver instance to wrap and manage.</param>
/// <param name="reconnectInterval">Interval between reconnection attempts.</param>
/// <param name="startStubbed">If true, start in stub mode for testing or unavailable platforms.</param>
public DriverInstanceActor(IDriver driver, TimeSpan reconnectInterval, bool startStubbed = false)
/// <param name="healthPublisher">Sink for health-change notifications; must not be null.</param>
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
public DriverInstanceActor(
IDriver driver,
TimeSpan reconnectInterval,
bool startStubbed = false,
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null)
{
_driver = driver;
_driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_reconnectInterval = reconnectInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
@@ -121,6 +167,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <inheritdoc />
protected override void PreStart()
{
// Warm up the snapshot store immediately so AdminUI sees current state as soon as the
// actor starts, before any state transition fires. Also start the periodic heartbeat so
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
PublishHealthSnapshot();
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval);
}
private void Stubbed()
{
// Stubbed drivers accept the standard message contracts but return deterministic
@@ -129,6 +185,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
Receive<DisconnectObserved>(_ => { /* stubbed drivers don't disconnect */ });
Receive<ForceReconnect>(_ => { /* stubbed drivers don't reconnect */ });
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
}
private void Connecting()
@@ -138,12 +196,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
Become(Connected);
PublishHealthSnapshot();
});
Receive<InitializeFailed>(msg =>
{
_log.Warning("DriverInstance {Id}: initialize failed: {Reason}", _driverInstanceId, msg.Reason);
RecordFault();
Become(Reconnecting);
PublishHealthSnapshot();
});
Receive<ForceReconnect>(_ => { /* already connecting — no-op */ });
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
}
private void Connected()
@@ -154,12 +217,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
_driverInstanceId, msg.Reason);
DetachSubscription();
RecordFault();
Become(Reconnecting);
PublishHealthSnapshot();
});
Receive<ForceReconnect>(_ =>
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription();
Become(Reconnecting);
PublishHealthSnapshot();
});
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
ReceiveAsync<Unsubscribe>(_ => UnsubscribeAsync());
Receive<DataChangeForward>(OnDataChangeForward);
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
}
private void Reconnecting()
@@ -170,8 +243,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Timers.Cancel("retry-connect");
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
Become(Connected);
PublishHealthSnapshot();
});
Receive<InitializeFailed>(_ => { /* keep retrying via timer */ });
Receive<ForceReconnect>(_ => { /* already reconnecting — no-op */ });
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
}
@@ -336,6 +412,48 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
/// <summary>
/// Records a transition into a Faulted / error state for the 5-minute sliding counter.
/// Thread-safe: called from actor message-handling (single-threaded) but guard is cheap.
/// </summary>
private void RecordFault()
{
lock (_faultLock)
{
_faultTimestamps.Enqueue(DateTime.UtcNow);
}
}
/// <summary>Returns how many fault transitions occurred in the last 5 minutes.</summary>
private int ErrorCount5Min()
{
var cutoff = DateTime.UtcNow.AddMinutes(-5);
lock (_faultLock)
{
while (_faultTimestamps.Count > 0 && _faultTimestamps.Peek() < cutoff)
_faultTimestamps.Dequeue();
return _faultTimestamps.Count;
}
}
/// <summary>
/// Polls <see cref="IDriver.GetHealth"/> and forwards the snapshot to the health publisher.
/// Called on every observable state change and by the periodic <see cref="HealthPollTick"/>
/// so the AdminUI snapshot store is warmed up for newly-joined SignalR clients.
/// </summary>
private void PublishHealthSnapshot()
{
try
{
var health = _driver.GetHealth();
_healthPublisher.Publish(_clusterId, _driverInstanceId, health, ErrorCount5Min());
}
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: GetHealth threw during health publish; skipping", _driverInstanceId);
}
}
/// <inheritdoc />
protected override void PostStop()
{
@@ -42,6 +42,7 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
services.TryAddSingleton<IOpcUaAddressSpaceSink>(NullOpcUaAddressSpaceSink.Instance);
services.TryAddSingleton<IServiceLevelPublisher>(NullServiceLevelPublisher.Instance);
services.TryAddSingleton<IDriverHealthPublisher, AkkaDriverHealthPublisher>();
return services;
}
@@ -87,6 +88,7 @@ public static class ServiceCollectionExtensions
var addressSpaceSink = resolver.GetService<IOpcUaAddressSpaceSink>() ?? NullOpcUaAddressSpaceSink.Instance;
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
var dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
@@ -116,7 +118,8 @@ public static class ServiceCollectionExtensions
DriverHostActor.Props(dbFactory, roleInfo.LocalNode, coordinator: null,
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
dependencyMux: mux,
opcUaPublishActor: publishActor),
opcUaPublishActor: publishActor,
healthPublisher: healthPublisher),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);
@@ -89,6 +89,12 @@ public static class ServiceCollectionExtensions
JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
o.AddPolicy("DriverOperator", policy =>
policy.RequireRole("DriverOperator", "FleetAdmin"));
});
return services;
@@ -0,0 +1,17 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class AbLegacyAddressBuilderTests
{
[Theory]
[InlineData("N", 7, 0, "N7:0")]
[InlineData("B", 3, 1, "B3:1")]
[InlineData("F", 8, 12, "F8:12")]
[InlineData("T", 4, 0, "T4:0")]
[InlineData("C", 5, 2, "C5:2")]
public void Build_Canonical(string fileType, int fileNumber, int element, string expected)
=> AbLegacyAddressBuilder.Build(fileType, fileNumber, element).ShouldBe(expected);
}
@@ -0,0 +1,16 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class FocasAddressBuilderTests
{
[Theory]
[InlineData("axis", 5, "axis:5")]
[InlineData("spindle", 0, "spindle:0")]
[InlineData("program", 100, "program:100")]
[InlineData("status", 1, "status:1")]
public void Build_Canonical(string group, int parameterId, string expected)
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
}
@@ -0,0 +1,15 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class HistorianWonderwareAddressBuilderTests
{
[Theory]
[InlineData("SysTimeHour", "Cyclic", 60, "SysTimeHour?mode=Cyclic&interval=60")]
[InlineData("ReactorTemp", "Last", 1, "ReactorTemp?mode=Last&interval=1")]
[InlineData("FlowRate", "Delta", 30, "FlowRate?mode=Delta&interval=30")]
public void Build_Canonical(string tag, string mode, int interval, string expected)
=> HistorianWonderwareAddressBuilder.Build(tag, mode, interval).ShouldBe(expected);
}
@@ -0,0 +1,21 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class ModbusAddressBuilderTests
{
[Theory]
[InlineData("Holding", 1, 1, "4x00001-1")]
[InlineData("Coil", 0, 1, "0x00000-1")]
[InlineData("Holding", 123, 4, "4x00123-4")]
[InlineData("DiscreteInput", 5, 1, "1x00005-1")]
[InlineData("Input", 99999, 125, "3x99999-125")]
public void Build_Canonical(string type, int offset, int length, string expected)
=> ModbusAddressBuilder.Build(type, offset, length).ShouldBe(expected);
[Fact]
public void Build_UnknownType_FallsBackToHolding()
=> ModbusAddressBuilder.Build("Unknown", 1, 1).ShouldBe("4x00001-1");
}
@@ -0,0 +1,20 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
public sealed class S7AddressBuilderTests
{
[Theory]
[InlineData("DB", 10, 20, "REAL", "DB10.DBD20:REAL")]
[InlineData("DB", 1, 0, "X", "DB1.DBX0.0:X")]
[InlineData("DB", 5, 4, "W", "DB5.DBW4:W")]
[InlineData("DB", 2, 6, "B", "DB2.DBB6:B")]
[InlineData("M", 1, 0, "X", "M0.0:X")]
[InlineData("M", 1, 4, "W", "M4:W")]
[InlineData("I", 1, 0, "B", "I0:B")]
[InlineData("Q", 1, 2, "W", "Q2:W")]
public void Build_Canonical(string area, int dbNumber, int offset, string s7Type, string expected)
=> S7AddressBuilder.Build(area, dbNumber, offset, s7Type).ShouldBe(expected);
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
@@ -19,7 +20,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref));
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
@@ -60,7 +61,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
}
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref));
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
@@ -0,0 +1,60 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Lightweight TCP-connect probe used by E2E integration tests to detect whether a Docker
/// fixture is reachable before attempting live work. Tests skip cleanly when this returns
/// <c>false</c>; CI with fixtures available lets them run.
/// </summary>
public static class DockerFixtureAvailability
{
/// <summary>
/// Attempts a TCP connect to <paramref name="host"/>:<paramref name="port"/>. Returns
/// <c>true</c> if the connection is accepted within <paramref name="timeoutMs"/>
/// milliseconds; <c>false</c> on refusal, timeout, or DNS failure.
/// </summary>
/// <param name="host">The host to probe.</param>
/// <param name="port">The TCP port to connect to.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
public static bool IsReachable(string host, int port, int timeoutMs = 500)
{
try
{
// Force IPv4 — remote Docker host binds only on IPv4 (0.0.0.0).
using var client = new TcpClient(AddressFamily.InterNetwork);
var ipv4 = System.Net.Dns.GetHostAddresses(host)
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Parse(host);
var task = client.ConnectAsync(ipv4, port);
return task.Wait(timeoutMs) && client.Connected;
}
catch
{
return false;
}
}
/// <summary>
/// Parses an <c>HOST:PORT</c> endpoint string and probes reachability.
/// Returns <c>true</c> if the connection succeeds within <paramref name="timeoutMs"/>
/// milliseconds. Handles malformed strings gracefully by returning <c>false</c>.
/// </summary>
/// <param name="endpoint">Endpoint in <c>host:port</c> format.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
public static bool IsReachable(string endpoint, int timeoutMs = 500)
{
try
{
var parts = endpoint.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[1], out var port))
return false;
return IsReachable(parts[0], port, timeoutMs);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>ReconnectDriver</c> command path through
/// <see cref="IAdminOperationsClient"/>.
///
/// <para><b>Scope note:</b> wiring a live <c>DriverInstanceActor</c> for the full
/// Healthy → Reconnecting → Healthy health-transition assertion requires a deployed
/// driver row in the config DB, a real fixture endpoint, and the
/// <c>DriverHostActor</c> to have registered the instance — substantially more
/// harness complexity than the two-node cluster setup alone provides. That deeper
/// fixture is tracked as a follow-up. This suite instead verifies the message
/// round-trip through the <c>AdminOperationsActor</c> singleton: the command is
/// accepted, persisted as a <c>ConfigEdit</c> audit row, and the reply carries
/// <c>Ok = true</c> with the matching <c>CorrelationId</c>. The DPS broadcast
/// that triggers the actor-side reconnect is exercised by the control-plane unit
/// tests that mock <c>IActorRef</c>.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverReconnectE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="ReconnectDriver"/> message dispatched through
/// <see cref="IAdminOperationsClient.AskAsync{T}"/> returns a
/// <see cref="ReconnectDriverResult"/> with <c>Ok = true</c> and the matching
/// correlation ID, confirming the cluster-singleton round-trip works end-to-end.
///
/// <para>The instance ID used here ("reconnect-e2e-nonexistent") does not correspond
/// to a deployed driver, so no <c>DriverInstanceActor</c> will act on the DPS
/// broadcast — the test is validating the command ingestion and reply path only.</para>
/// </summary>
[Fact]
public async Task Reconnect_RoundTrip_ReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var correlationId = Guid.NewGuid();
var msg = new ReconnectDriver(
ClusterId: "cluster-e2e-test",
DriverInstanceId: "reconnect-e2e-nonexistent",
ActorByUserName: "e2e-test-runner",
CorrelationId: correlationId);
var result = await client.AskAsync<ReconnectDriverResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"ReconnectDriver round-trip failed: {result.Message}");
result.Message.ShouldBeNull();
}
/// <summary>
/// Verifies that a second <see cref="ReconnectDriver"/> for the same instance ID
/// is also accepted (idempotent at the actor layer — the actor simply re-broadcasts
/// to DPS and writes another <c>ConfigEdit</c> row).
/// </summary>
[Fact]
public async Task Reconnect_IsIdempotent_SecondCallAlsoReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
const string instanceId = "reconnect-idempotency-test";
var first = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var second = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var r1 = await client.AskAsync<ReconnectDriverResult>(first, Ct);
var r2 = await client.AskAsync<ReconnectDriverResult>(second, Ct);
r1.Ok.ShouldBeTrue($"First call failed: {r1.Message}");
r2.Ok.ShouldBeTrue($"Second call failed: {r2.Message}");
r1.CorrelationId.ShouldBe(first.CorrelationId);
r2.CorrelationId.ShouldBe(second.CorrelationId);
}
}
@@ -0,0 +1,164 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>DriverStatusSignalRBridge</c> actor → snapshot
/// store → SignalR hub push pipeline.
///
/// <para><b>Scope note:</b> wiring a full SignalR hub connection from inside an
/// integration test requires an HTTP listener, JWT authentication (the hub has
/// <c>[Authorize]</c>), and a real WebSocket upgrade — significantly more plumbing
/// than the two-node harness provides out of the box. Full-stack hub connectivity is
/// covered by the Playwright smoke tests in the manual runbook (§8.3). This suite
/// instead exercises the bridge actor directly: it spawns a
/// <see cref="DriverStatusSignalRBridge"/> inside the harness actor system, publishes
/// a <see cref="DriverHealthChanged"/> to the <c>driver-health</c> DPS topic, and
/// asserts that (a) the snapshot store is updated and (b) the mock
/// <see cref="IHubContext{DriverStatusHub}"/> receives a <c>SendAsync</c> call with
/// the matching <c>DriverInstanceId</c>. This validates the bridge actor's DPS
/// subscription, store write, and hub-push code paths without a live HTTP client.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverStatusHubE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="DriverHealthChanged"/> published to the
/// <c>driver-health</c> DPS topic is forwarded by <see cref="DriverStatusSignalRBridge"/>
/// to both the <see cref="IDriverStatusSnapshotStore"/> (via <c>Upsert</c>) and the
/// mock <see cref="IHubContext{DriverStatusHub}"/> (via <c>SendAsync</c>).
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_ForwardsHealthChanged_ToStoreAndHub()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
// Resolve the snapshot store that AddAdminUI() wired into DI.
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
// Build a mock IHubContext<DriverStatusHub> that captures SendAsync calls.
var sentMessages = new List<(string method, object? arg)>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((method, args, _) =>
sentMessages.Add((method, args.FirstOrDefault())))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
// Spawn the bridge actor directly in the harness ActorSystem.
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-{Guid.NewGuid():N}");
// Wait for the DPS subscription to be acknowledged.
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
// Publish a DriverHealthChanged snapshot via DPS.
const string testInstanceId = "driver-hub-e2e-test-instance";
var snapshot = new DriverHealthChanged(
ClusterId: "cluster-e2e",
DriverInstanceId: testInstanceId,
State: "Healthy",
LastSuccessfulReadUtc: DateTime.UtcNow,
LastError: null,
ErrorCount5Min: 0,
PublishedUtc: DateTime.UtcNow);
DistributedPubSub.Get(harness.NodeASystem).Mediator.Tell(
new Publish(DriverStatusSignalRBridge.TopicName, snapshot));
// Wait up to 3s for the bridge actor to process the message and invoke the hub mock.
await WaitForAsync(
() => Task.FromResult(sentMessages.Count > 0),
TimeSpan.FromSeconds(3));
// Assert snapshot store was updated.
store.TryGet(testInstanceId, out var stored).ShouldBeTrue("Snapshot store should contain the published snapshot.");
stored.DriverInstanceId.ShouldBe(testInstanceId);
stored.State.ShouldBe("Healthy");
// Assert hub mock received the push on the expected method name.
sentMessages.ShouldNotBeEmpty("Hub mock should have received a SendAsync call.");
sentMessages[0].method.ShouldBe(DriverStatusHub.MethodName);
// Clean up actor to avoid lingering DPS subscription.
harness.NodeASystem.Stop(bridge);
}
/// <summary>
/// Verifies that publishing two consecutive <see cref="DriverHealthChanged"/> snapshots
/// for the same instance ID results in the store holding only the most recent state
/// (last-write-wins) and both hub push calls being made.
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_LastSnapshotWins_InStore()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
var hubCallCount = 0;
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((_, _, _) => Interlocked.Increment(ref hubCallCount))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-2-{Guid.NewGuid():N}");
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
const string instanceId = "driver-hub-last-write-wins";
var mediator = DistributedPubSub.Get(harness.NodeASystem).Mediator;
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Reconnecting", null, "lost connection", 1, DateTime.UtcNow)));
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
await WaitForAsync(
() => Task.FromResult(hubCallCount >= 2),
TimeSpan.FromSeconds(3));
// Store should reflect the most recent (Healthy) state.
store.TryGet(instanceId, out var stored).ShouldBeTrue();
stored.State.ShouldBe("Healthy");
hubCallCount.ShouldBeGreaterThanOrEqualTo(2);
harness.NodeASystem.Stop(bridge);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(100);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
}
@@ -0,0 +1,137 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>TestDriverConnect</c> /
/// <see cref="TestDriverConnectResult"/> round-trip through the
/// <c>AdminOperationsActor</c> cluster singleton.
///
/// <para>All three tests target the Modbus Docker fixture that ships with the
/// <c>lmxopcua-fix up modbus</c> profile (default endpoint
/// <c>10.100.0.35:5020</c> from <c>MODBUS_SIM_ENDPOINT</c>). They are skipped
/// automatically when the fixture is unreachable so <c>dotnet test</c> on a dev
/// machine without Docker access still exits clean.</para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Driver", "Modbus")]
public sealed class DriverTestConnectE2eTests
{
private const string DefaultEndpoint = "10.100.0.35:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Resolves the Modbus sim endpoint from the environment (or falls back to the shared
/// Docker host default) and returns host + port separately.
/// </summary>
private static (string host, int port) ResolveSimEndpoint()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
var host = parts[0];
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 5020;
return (host, port);
}
/// <summary>
/// Happy-path probe: connects to the running Modbus pymodbus simulator and asserts
/// the <see cref="TestDriverConnectResult"/> reports <c>Ok = true</c> with a
/// sub-5 s latency. Skipped when the Docker fixture host is unreachable.
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstFixture_ReportsOk()
{
var (host, port) = ResolveSimEndpoint();
if (!DockerFixtureAvailability.IsReachable(host, port))
Assert.Skip($"Modbus fixture at {host}:{port} unreachable — start with `lmxopcua-fix up modbus standard`.");
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":{port}}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 10, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"Probe reported failure: {result.Message}");
result.LatencyMs.ShouldNotBeNull();
result.LatencyMs!.Value.ShouldBeLessThan(5_000);
}
/// <summary>
/// Wrong-port probe: connects to the Docker fixture host on port 9999 (nothing
/// listens there) and asserts the result is <c>Ok = false</c> with a message
/// containing a connection-refused indicator. Skipped when the host is unreachable
/// (even a refused connection requires the IP to be routable).
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstWrongPort_ReportsFailure()
{
var (host, _) = ResolveSimEndpoint();
// Reachability check on the *correct* port to confirm the host is routable.
var (_, goodPort) = ResolveSimEndpoint();
if (!DockerFixtureAvailability.IsReachable(host, goodPort))
Assert.Skip($"Modbus fixture host {host} not routable — cannot exercise refused-connection scenario.");
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":9999}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 5, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeFalse("Port 9999 should not be open.");
result.Message.ShouldNotBeNull();
// SocketErrorCode is ConnectionRefused on Windows/Linux; on macOS it may appear as
// "Connection refused" in the message text rather than the enum name.
var failureMessage = result.Message!;
(failureMessage.Contains("ConnectionRefused", StringComparison.OrdinalIgnoreCase)
|| failureMessage.Contains("refused", StringComparison.OrdinalIgnoreCase)
|| failureMessage.Contains("Connect failed", StringComparison.OrdinalIgnoreCase))
.ShouldBeTrue($"Expected a refused-connection message but got: {failureMessage}");
}
/// <summary>
/// Black-hole probe: targets <c>1.2.3.4:502</c> (TEST-NET-1, packets are dropped).
/// Uses a 3-second timeout to keep the wall-clock cost low. Asserts the probe
/// reports <c>Ok = false</c> with a message containing "timed out".
///
/// <para><b>Scope:</b> the <see cref="TwoNodeClusterHarness"/> does not register
/// <c>IDriverProbe</c> implementations (those are wired by
/// <c>DriverFactoryBootstrap</c> in Program.cs). This test therefore exercises
/// <see cref="ModbusDriverProbe.ProbeAsync"/> directly rather than going through
/// the <c>AdminOperationsActor</c>. The cluster round-trip path is covered by
/// <see cref="TestConnect_Modbus_AgainstFixture_ReportsOk"/> (which skips in dev).
/// This test does NOT require any Docker fixture and always runs.</para>
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstBlackHole_ReportsTimeout()
{
var probe = new ModbusDriverProbe();
// 1.2.3.4 is TEST-NET-1 (RFC 5737) — routable but black-holed; packets never return.
const string configJson = "{\"Host\":\"1.2.3.4\",\"Port\":502}";
var timeout = TimeSpan.FromSeconds(3);
using var cts = new CancellationTokenSource(timeout);
var result = await probe.ProbeAsync(configJson, timeout, cts.Token);
result.Ok.ShouldBeFalse("TEST-NET-1 connection should time out.");
result.Message.ShouldNotBeNull();
result.Message!.ToLowerInvariant().ShouldContain("timed out");
}
}
@@ -8,13 +8,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer"/>
<PackageReference Include="Akka.Hosting"/>
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="Shouldly" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Akka.Hosting" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -22,16 +23,16 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj" />
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3" />
</ItemGroup>
</Project>