Compare commits
10 Commits
29370fde3c
...
64e4726fff
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e4726fff | |||
| 494da22cd1 | |||
| 063005fefa | |||
| ffcc8d1065 | |||
| 4b374fd177 | |||
| 54f0dbddb9 | |||
| c19d124e89 | |||
| f3f328c25c | |||
| 4584612a1a | |||
| 4203b84d51 |
@@ -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 2–4 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an `opc.tcp://` endpoint.
|
||||
|
||||
If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.
|
||||
|
||||
### 8.4 bUnit harness
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+35
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+34
@@ -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); }
|
||||
|
||||
+299
@@ -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…</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="padding:1rem">
|
||||
@if (!Enabled)
|
||||
{
|
||||
<p class="mb-0" style="color:var(--ink-soft)">Disabled — 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…</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…</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…</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);
|
||||
}
|
||||
}
|
||||
+82
@@ -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 · @_result.LatencyMs?.ToString("F0") ms
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad" title="@_result.Message">
|
||||
Failed · @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();
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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}";
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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}";
|
||||
}
|
||||
+57
@@ -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}";
|
||||
}
|
||||
}
|
||||
+12
@@ -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&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}";
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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}";
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+15
@@ -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");
|
||||
}
|
||||
}
|
||||
+15
-14
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user